说起XSS,恐怕再没有哪类漏洞的"名声"和"真实危害"差距这么大了。练习平台上第一关永远是弹个alert(1),导致很多人觉得XSS不就是"让页面执行一下我写的JS"么,有什么好大惊小怪的;可到了真实应用里,XSS常年霸占高危漏洞榜前列,原因只有一个:它利用的是浏览器最底层的解析机制,而几乎每个开发者都在写"把用户数据拼进页面"的代码。
这篇内容写给两类读者:一是刚接触Web安全、被反射型、存储型、DOM型几个术语绕晕的新手,想系统搞清"XSS到底是什么"以及为什么要靠"通关"来练;二是已经在刷XSS通关靶场、却总是背Payload、一换过滤规则就卡壳的进阶学习者。我会把XSS的原理讲透,给出一套能贯穿绝大多数关卡的排查思路和绕过策略,最后聊聊通关之后真正该掌握的防御姿势——毕竟,能打进去只是第一步,会防得住才算真正入了门。
1. XSS的本质:浏览器把"数据"当成了"代码"
1.1 浏览器根本不区分"开发者写的代码"和"用户提交的数据"
这是很多人对XSS最大的认知误区。你在浏览器里打开一个页面,得到的是服务器已经拼好的HTML字符串。服务器端代码在生成页面时,如果直接把用户输入拼接进HTML,那么输入里的<script>、onerror这类内容,到了浏览器这边就是HTML语法的一部分。浏览器不会回头看"这个是开发者故意写的,那个是用户填的",它只负责按HTML规范把字符流解析成DOM、把脚本执行起来。
可以这么类比:你开了一间广播站,来电话的人说什么你都直接对着麦克风播出去。有一天来电的人说"我念到'今天天气不错'之后,紧接着说一句'现在所有听众起立'",你照单全收,结果听众听到的不是天气,而是你的广播被劫持了。XSS就是这种"话被原样播出、还带上了执行效果"的漏洞——只不过广播站变成浏览器,说话的内容变成带HTML和JavaScript语法的字符串。
所以判断一个地方有没有XSS,不要只看"有没有过滤特殊字符",要看用户输入最终是否以"可执行代码"的形态到达了浏览器。过滤做得不好的站点,可能防住了<script>却被<img onerror>打穿;而有些看起来完全没过滤的页面,如果输出点被放在纯文本节点里,反而可能天然安全。关键永远在"输出上下文"。
1.2 漏洞成立的四个条件,缺一不可
结合我做漏洞测试的经验,一个XSS要成立,通常要同时满足四个前提:
- 存在可控入口:数据来自URL参数、表单字段、请求头、路由参数等,至少有一个是攻击者能改的。
- 数据进入了页面输出链路:要么被服务器拼进响应页(反射型和存储型的共同前提),要么被前端脚本读走后写进页面(DOM型的前提)。
- 输出环节没有做正确的上下文编码:这是最核心的一条。说直白些,开发者没有告诉浏览器"这段是数据,不是代码"。
- 输入最终落在可执行上下文:比如HTML标签之间、标签属性值内部、JavaScript字符串内部、URL属性里。同一个字符串放在
<textarea>里就是纯文本,放在<script>字符串里就可能断句执行。
把第三条和第四条放在一起看,你就明白为什么很多号称"过滤了XSS"的代码依然会被打穿——它们把用户输入过滤一遍,再用拼接的方式放进页面,只要过滤规则有遗漏,比如只处理了<script>却没处理onerror,或者只处理了英文双引号却没处理反引号,就很容易被绕过。后面讲通关套路时,我会反复回到"上下文"这个词。
1.3 名字里的"跨站"到底是什么意思
很多人对"跨站脚本"这个译名很困惑:我的Payload明明就注入在目标页面自身,哪来的"跨站"?这其实是历史遗留的叫法。早期的利用方式倾向于在一个站点里注入指向攻击者站点的脚本标签,例如<script src="//攻击者域名/x.js"></script>,让受害者浏览器去"跨站"加载恶意脚本,于是得名Cross-Site Scripting。现在随着前端应用越来越复杂,现代XSS早已不局限于"跨站加载",直接内联执行、DOM级利用都很常见,但安全圈仍然沿用XSS这个缩写。
搞懂这个名字还有个实际作用:它会提醒你,XSS的受害者是"浏览器使用者"而不是"服务器"。这决定了它的危害形态和修复思路都和其他漏洞很不一样——服务器端能做的只是尽量不让恶意代码进入页面,真正被利用的环节发生在每个访问者的浏览器里。
2. 反射型、存储型、DOM型:三种XSS的判型方法论
2.1 反射型XSS:一次性利用,链路在"请求-响应"之间
反射型XSS是最容易理解的形态。攻击者把Payload放在请求的URL参数里,服务器不做存储,直接把它反射回响应页面。典型场景是搜索框:/search?kw=...,服务器把关键词原样放进"您搜索的内容是XXX"的页面里,不经过任何编码。
比如一个极简的渲染逻辑:
<!DOCTYPE html> <html> <body> <?php echo "你搜索的关键词是:" . $_GET['kw']; ?> </body> </html>把URL构造成:
/search?kw=<script>alert(1)</script>响应里就会出现<script>alert(1)</script>,浏览器一解析就执行。这种漏洞的利用必须"欺骗"受害者点击攻击者构造好的链接,所以通常出现在钓鱼链、短链接、社交工程等场景里。从排查角度来说,看到"搜索、翻页、排序、错误提示"这类GET参数直接被拼进页面,优先怀疑反射型。
2.2 存储型XSS:落库即持久,危害按"访问人次"计算
存储型和反射型的区别只有一个关键点:数据是否被服务器持久化。评论区、用户昵称、个人签名、富文本编辑内容、上传文件名的展示,都是存储型XSS的高发地。攻击者提交一次Payload,之后每一个访问页面的人都会中招。
在真实业务里,存储型XSS是三类中危害最大的。原因很直接:反射型还要诱导单个受害者点击链接,存储型直接变成"挂机自动攻击",而且中招的往往还包括管理员等高权限用户。想象一下,一个精心构造的帖子标题,管理员在后台列表里鼠标悬停看了一眼,标题里的onmouseover事件就执行了——如果执行的是"修改管理员密码"的接口调用,整个后台就等于交了出去。
存储型XSS的排查特征是"入口和出口分离、且出口被很多人访问"。代码审计时不能只看提交接口,还得看所有渲染这个字段的地方。
2.3 DOM型XSS:服务器全程不知情
DOM型XSS是最容易被新手忽略的一类,因为它根本不经过服务器。漏洞源在页面自身的JavaScript:某个前端脚本读取了可以被攻击者控制的数据源(location.search、location.hash、document.referrer、localStorage等),然后把它直接写进页面的可执行位置。
一个非常典型的漏洞写法:
var name = new URLSearchParams(location.search).get('name'); document.getElementById('greeting').innerHTML = '你好,' + name;访问https://example.com/?name=<img src=x onerror=alert(1)>,URL参数被JS读取后,通过innerHTML写入DOM,图片加载失败触发onerror,脚本执行。整个过程服务器只返回了一个毫无变化的页面,所以你在服务器日志里根本看不出问题,必须去读前端代码。
这也是为什么前后端分离、页面大量使用前端渲染的项目里,DOM型XSS的比例越来越高。排查DOM型需要关注的API包括innerHTML、outerHTML、document.write、eval、setAttribute、location相关的赋值等,凡是"外部数据 + 写DOM或执行代码"的组合都要过一遍。
2.4 一张表搞定判型与排查优先级
| 类型 | 触发链路 | 是否入库 | 利用条件 | 危害规模 | 排查重点 |
|---|---|---|---|---|---|
| 反射型 | 请求 → 服务器 → 响应 | 否 | 诱导受害者点击构造链接 | 单个受害者/单次 | URL参数拼接点 |
| 存储型 | 请求 → 服务器 → 入库 → 响应 | 是 | 只需提交一次 | 所有访问者,持续触发 | 所有渲染用户可控字段的位置 |
| DOM型 | 请求 → 前端脚本 → DOM | 否(只存在于浏览器侧) | 诱导受害者访问构造URL | 单个受害者/单次,但难排查 | 前端JS中的敏感DOM写入API |
判型有一个非常实用的技巧:先看"输出点"而不是"输入点"。在响应源码里搜索自己提交的测试字符串,如果它出现在服务器返回的HTML里,就是反射型或存储型(再根据是否入库区分);如果响应源码里根本没有你提交的字符串、页面里却弹窗了,那几乎可以断定是DOM型——数据在中间绕了一圈才被前端脚本拾起来。
3. 破坏力被严重低估:XSS为什么常年排在高危风险前列
3.1 从"证明漏洞"到"实际利用":alert(1)只是个起步
练习平台上用alert(1)证明代码执行,是因为它足够显眼、一眼就能确认,但这也给新手造成了一个错觉:XSS的利用就是弹个窗。真实的攻击载荷几乎不会用alert,因为弹窗本身对攻击者没有收益。我习惯把攻击载荷分成几档来看:
- 信息窃取档:读取当前页面的敏感内容、Cookie、表单输入、本地存储,并通过网络请求发送到攻击者控制的服务器。
- 行为模拟档:在受害者浏览器上下文里以受害者的身份发请求,改资料、下单、转账、发消息,服务器根本分辨不出来。
- 隐蔽劫持档:注入键盘记录脚本、篡改页面显示内容、伪造登录框,让用户主动把账号密码交出来。
用一句话概括:在浏览器眼里,攻击者脚本和页面自己的脚本没有任何区别。页面能访问的,攻击载荷就能访问;用户能操作的,攻击载荷就能替用户操作。
3.2 窃取会话、页面钓鱼、键盘记录:具体能做到什么程度
先说最经典的Cookie窃取。如果Cookie没有设置HttpOnly标志,页面里任何脚本都能通过document.cookie读到它,然后发给远程服务器。即便设置了HttpOnly,也只是挡住了"直接读Cookie"这一条路,XSS脚本仍然能在受害者的会话里直接发请求、调用各种接口,等于借用户的身份"办事",这比偷Cookie更难防御。
还有一类利用是"页面内嵌钓鱼"。利用XSS直接在受害者正在访问的合法域名下注入一个虚假的登录框或中奖通知,用户看到的地址栏是正确的、页面也是对的,只是多出一段攻击者画的"官方页面"。这种依托于真实域名的钓鱼,成功率比外部钓鱼链接高出一个量级。
键盘记录也不难实现:给document挂一个keydown监听器,把每次按键内容收集起来回传。用户输入支付密码、账号、搜索敏感词,全都变成攻击者的数据。这类脚本往往写得很小、很隐蔽,普通用户根本察觉不到。
3.3 从单一漏洞到连锁利用:XSS如何引发"系统性崩盘"
实战中最怕的不是单点XSS,而是XSS作为跳板引发的连锁反应。举一个我在测试某后台管理系统时遇到的例子:一个列表页的导出参数存在存储型XSS,管理员登录后访问列表页就会触发。攻击载荷里先采集了当前页面的接口返回,又利用管理员的会话调用了"创建后台账号"的接口,几分钟之内,攻击者就拿到了一个入口。这不是特例——后台管理系统功能全面、权限极高、前端却往往缺乏安全编码习惯,这类系统里一个存储型XSS就等于整个后台的钥匙。
所以安全社区把XSS列入Web应用最高危风险并不是危言耸听。它的输入面极其分散——搜索、昵称、评论、简历、富文本编辑器、上传文件名、URL参数,几乎每个功能模块都可能是入口;而它的危害又跟着受害者权限走——普通用户的XSS只能影响他自己,管理员的XSS能影响整个平台。
4. "通关"的真正意义:练习平台为什么都设计成关卡制
4.1 关卡制的本质:把XSS能力拆解成可重复训练的最小单元
市面上的XSS练习平台,无论公开在线还是本地自建,几乎都采用关卡制,这和编程刷题是同一个道理:把"XSS漏洞发现与利用"这个宏大能力,拆成一关一个知识点的粒度。第一关通常是一个完全没有过滤的简单输出点,让你熟悉"什么叫代码执行";第二关开始引入关键字过滤;第三关考验属性上下文;再往后是各种编码叠加。每一关都是真实漏洞的某种简化投影。
我挺建议学习者在刷这类平台时,把每一关的"考点"记下来,而不是只记通关Payload。因为这些考点在真实漏洞里反复出现:<script>被过滤时要用事件处理器,引号被过滤时要考虑无引号写法,输出在JS字符串里要考虑断句闭合……你把这几个考点练熟了,以后看真实代码时思路会快很多。
4.2 关卡最常卡的三个维度:过滤、上下文、编码
刷过十几关之后你就会发现,关卡设计绕不开三个维度。
第一个维度是过滤规则。平台通常会在服务端或前端对输入做处理:有的删除<script>关键字,有的把尖括号转成实体,有的替换引号,有的干脆限制长度。你要做的是先判断"它到底过滤了什么、怎么过滤的",再针对性地绕过,而不是上来就试一堆Payload。
第二个维度是输出上下文。Payload放在HTML正文、放在value="..."属性里、放在<script>字符串里,写法完全不同。这个维度决定了"断句"的姿势:属性里要先闭合引号,脚本字符串里要先闭合字符串和语句,URL属性里可能直接使用javascript:协议。
第三个维度是编码。HTML实体、URL编码、JavaScript的Unicode转义,浏览器在不同阶段会做不同解码。很多"看似双重过滤"的关卡,本质就是考察你对浏览器解码顺序的理解——某个字符被过滤层转成实体后,浏览器又把它解码回来,再交给下一个解析阶段。
一关高难度的关卡往往是三个维度叠加:先判断上下文,再分析过滤,最后考虑编码。这个分析链条就是通关的核心竞争力。
4.3 通关的正确打开方式:别背Payload,练"假设-验证"循环
我最常跟新人说的一句话是:Payload库只能帮你过前三关,后面的关卡靠的是方法。推荐的做法是养成一个"假设-验证"的循环:
- 先提交一个独特、容易识别的探针字符串,比如
z"xss'<y>,目的是让它在响应源码里变得刺眼。 - 打开开发者工具或直接查看响应源码,找到这个字符串的落点,观察它被放在什么标签里、引号和尖括号发生了哪些变化。
- 基于观察结果形成假设:"这里的过滤器把尖括号转义了,所以不能闭合标签,但属性里的引号保留了……"
- 写一个针对这个假设的Payload,提交验证,不行就回退修正假设。
这套循环看起来慢,实际上才是职业渗透测试者的工作方式。拿真实业务系统也一样:先测探针、看响应、定上下文、构造载荷。把刷关卡当成练这套循环,比背一百个Payload有价值得多。
5. 一套能打到中后期关卡的通用排查流程
5.1 第一步:先全局摸清"数据从哪里进,最终到哪里出"
拿到任何一道XSS题目,我都不会立刻扔Payload。先做的事是把输入点从头到尾走一遍:它在URL参数里还是在表单字段里?提交之后请求到了服务器哪里?响应页面中哪个位置出现了我的输入?如果是前端渲染,它又是被哪段脚本读走的?
为了把这条链路摸清楚,最实用的手段就是探针字符串。找一个很独特的值,比如:
xssprobe_"'<abc>把它填进输入点提交,然后在响应源码里搜索xssprobe。看到它的那一刻,你至少能回答三个问题:第一,我的输入原本样貌是什么样的(尖括号和引号有没有被转义);第二,这个输入落在什么标签、什么属性、什么脚本块里;第三,服务器或前端在中间做了哪些处理。这三个问题基本就把漏洞的"骨架"勾勒出来了。
这一步很多人会跳过,觉得浪费时间,但真实渗透里恰恰是这一步最值钱。你只有知道输出点在哪,才知道后面所有Payload往哪里打。
5.2 第二步:判定输出上下文,这是选Payload的唯一依据
探针落点清楚了,接下来给上下文归类。XSS的输出上下文大致分四类:
| 上下文 | 特征 | 典型Payload思路 |
|---|---|---|
| HTML标签内容 | 输入夹在<div>...</div>之间 | <script>alert(1)</script>或<img src=x onerror=alert(1)> |
| HTML属性值 | 输入在value="..."、title="..."等属性里 | "><script>alert(1)</script>或" onfocus=alert(1) x=" |
| JavaScript代码 | 输入被拼进<script>、事件属性、onclick等 | ';alert(1);// |
| URL属性 | 输入在href、src里 | javascript:alert(1) |
判断方法其实很朴素:在响应源码里看探针被哪对标签或引号包裹着。被<div>包着就是HTML内容上下文,被value="包着就是属性上下文,被var x = '包着就是JS字符串上下文。判错了上下文,Payload再精妙也不会执行,因为浏览器根本不在那个语境里解析它。
5.3 第三步:用探针字符串摸清过滤规则
判定上下文后,还要知道过滤规则到底改动了什么。此时可以换一组更有针对性的探针,把容易触发过滤的特征字符拆开测试:
< > " ' / \ script onerror img一次提交一个或一组,看响应里哪个被删了、哪个被转义了、哪个原样保留。这些观察结果直接决定你后面Payload的写法,比如:
- 尖括号被编码成
<和>:HTML内容上下文这条路基本断了,得考虑属性上下文,或者找有没有其他未过滤的输出点。 <script>关键字被删除,但尖括号原样保留:说明是黑名单拦截,可以试试<img>、<svg>、<iframe>等标签,或者用<scr<script>ipt>让过滤后的字符串重新拼接成合法的<script>。- 引号被过滤或编码,但尖括号保留:Payload里尽量不用引号,事件属性里可以写成
onerror=alert(1),JS上下文可以用反引号或String.fromCharCode拼字符。 - 空格被过滤:用
/、制表符、换行、注释符/**/代替HTML属性里的分隔。
这一步做完,过滤规则的画像基本就出来了。别再奢望"猜中唯一正确的Payload",先搞清楚规则,正确的Payload就是规则下的自然推论。
5.4 第四步:按上下文选载荷,按过滤做变形
上下文和过滤规则都清楚了,最后一步才是选载荷。组合逻辑一句话就能说清:先按上下文选基础形态,再按过滤规则做变形。
举个例子。HTML正文上下文、无过滤,直接用<script>alert(1)</script>。但如果发现script关键字被删,基础形态就要换成事件触发型:<img src=x onerror=alert(1)>,因为img标签不在黑名单里,onerror也还在。如果连onerror也被删,那就继续换思路:<svg/onload=alert(1)>、<details open ontoggle=alert(1)>,或者用onerror的编码变体——关键在于你始终在"上下文允许这个标签或事件的语法"和"过滤规则不知道这个变体"的交集里找路。
变形的手法也有套路可循:大小写混淆、关键字中间插入注释或标签、HTML实体编码、Unicode转义、字符拼接、利用未被过滤的等价标签。多练几关之后你会发现,所有变形都围绕着同一个目标:让过滤后的字符串在浏览器解析时,重新变成"看起来合法的那一段"。
6. 五个常见关卡形态的实战拆解
这一节我用几个虚构但非常典型的关卡形态,把上面的流程从头到尾走一遍。这些形态不绑定任何真实平台,但你在任何一套XSS练习环境里都会遇到同类设计。
6.1 关卡形态A:输入点裸落在HTML正文中
场景很简单,一个"文章标题搜索"功能:
<div class="result">你搜索的内容是:<!-- 你的输入 --></div>提交探针xssprobe_"'<abc>,看响应源码,尖括号原样保留,没有过滤,上下文是HTML正文。那闭着眼都能过:输入本身就是标签语法。
Payload:
<script>alert(1)</script>或者更稳妥地,用不依赖script标签的写法:
<img src=x onerror=alert(1)>前者胜在直观,后者胜在即便script被部分过滤也能跑通,所以我在真实测试里习惯优先试事件型Payload。记住:这一关的考点是"认识HTML正文上下文",后面的关卡都是在它之上加过滤条件。
6.2 关卡形态B:script标签被过滤,改用事件处理器
同样的输出位置,这次服务端做了处理,把大小写不敏感的script关键字全部删除。提交<script>alert(1)</script>,响应源码里变成<alert(1)>,说明关键字被吃掉了,但尖括号还在。
这时候别再纠结怎么把script拼回来(虽然<scr<script>ipt>也能过,但更简洁的思路是换标签)。HTML正文上下文里,能执行JS的标签远不止script一个:
<img src=x onerror=alert(1)>浏览器加载图片失败,触发onerror事件,执行alert(1)。这个Payload里没有任何script关键字,过滤规则形同虚设。还可以换成:
<svg onload=alert(1)> <iframe src="javascript:alert(1)">这类"标签加事件"的组合,在真实漏洞里比<script>出现频率更高,因为开发者做黑名单时第一反应就是拦script,能想到拦onerror、onload这些事件的反而少。
6.3 关卡形态C:输入点被包在HTML属性值里
场景变成:
<input type="text" value="<!-- 你的输入 -->">这次探针显示输入被包在value="..."里,属于属性上下文。此时直接提交<script>alert(1)</script>是无效的——它只会被当作value属性的普通字符串的一部分,尖括号在属性值里没有"开辟新上下文"的能力,除非你先把属性值闭合掉。
所以这一关的核心动作是"断句闭合"。Payload:
" autofocus onfocus=alert(1) x="渲染后整个标签变成:
<input type="text" value="" autofocus onfocus="alert(1)" x="">第一个双引号闭合了value,autofocus让输入框自动聚焦,onfocus在聚焦时执行恶意代码,最后的x="是为了避免语法混乱而补的一个无意义属性。对比用"><script>alert(1)</script>"这种写法也能过,但事件型通常更短、更少依赖script标签。
属性上下文的判断信号很明确:探针前后是引号,且引号没有被编码。如果引号被转义成",那断句这条路就被堵上了,得考虑别的上下文或别的输出点。
6.4 关卡形态D:输入点被拼接进JavaScript代码
场景再升级,输出点变成:
<script>var keyword = '<!-- 你的输入 -->';</script>输入被拼进一个JS字符串里,这是非常经典的"脚本上下文"。此时提交<img src=x onerror=alert(1)>不会执行——它在JS字符串里只是一段普通文本,因为没有语法让它脱离字符串。正确思路是"闭合字符串,闭合语句,注释掉尾巴"。
Payload:
';alert(1);//渲染后变成:
var keyword = '';alert(1);//';解析顺序:'闭合了原本的字符串开头,;结束当前语句,alert(1)成为新的语句执行,//把后面残留的';注释掉,避免语法错误。这一关的考点是JS字符串的引号闭合和注释技巧,在真实代码里,任何把用户输入拼进<script>或事件属性的写法都是这个套路。
6.5 关卡形态E:多重过滤叠加,考验编码理解
最后一种综合形态:输入点在HTML正文,过滤器把<script>关键字删除,把onerror删除,还把英文引号编码了。看起来叠了三层规则,很多新手到这里开始堆Payload猜答案。
按前面的流程走:探针先上,确认三件事——尖括号原样保留、onerror被吃、引号变实体。上下文是HTML正文,但常规的<img onerror>路线被堵。此时想想还有没有别的"标签+事件"组合能绕过关键字拦截:onload还在,<svg>标签也还在。
Payload:
<svg onload=alert(1)>svg是合法标签,onload不在黑名单里,事件值里一个引号都没用,正好绕开三层过滤。渲染后浏览器载入SVG,onload触发,alert(1)执行。这个例子很好地说明了为什么我更看重"分析流程"而非"Payload收藏":分析得出的结论是"黑名单漏了onload和svg",顺着这个缺口走,比在onerror上死磕高效得多。
7. 从"通关"切换到"防御":攻防视角的最后一课
7.1 输出编码:按上下文编码才是唯一可靠的防线
刷完关卡,如果只记住一件事,我希望是:XSS的根治方案在输出端,而且是"按上下文编码",不是"防一下特殊字符"。服务器在把用户数据放进页面时,必须针对它将要落进的位置选择编码方式:
| 输出上下文 | 编码方式 | 编码示例 |
|---|---|---|
| HTML正文 | HTML实体编码 | <转<,>转>,&转& |
| 属性值(双引号内) | HTML实体编码 + 引号转义 | "转" |
| JavaScript字符串 | JS转义 | '转\x27,<转\x3C |
| URL属性 | URL编码 | 整体encodeURIComponent |
很多人写了多年业务代码,输出编码只会做一个 htmlspecialchars,遇到属性上下文、JS上下文照样被打穿,原因就是没有按上下文选编码。这也是为什么现代主流前端框架默认对插值做转义的原因——它们帮你把"上下文编码"这件事自动化了。但如果你用innerHTML、v-html、dangerouslySetInnerHTML这类强制插入HTML的接口,等于亲手关掉了框架的自动防护,这时候所有责任又回到开发者身上。
7.2 输入校验与纵深防御:白名单、CSP、HttpOnly的正确理解
输出编码之外,还有几层纵深加固。输入校验只应该做"业务合法性"的事,比如年龄字段必须是数字、手机号必须匹配格式,这类严格格式的字段用白名单校验即可;但绝不要指望输入校验能拦XSS,因为HTML和JS的表达方式太多,黑名单根本列不完。
CSP(内容安全策略)是真正有效的第二道防线。给站点配置script-src只允许从白名单域名加载脚本,禁止内联脚本,即使输出端漏了一个XSS,被注入的<script>或事件内联脚本也会被浏览器拦下。当然CSP也有使用成本,老项目里大量内联脚本会让策略配置变得很痛苦,需要逐步调整。
还有一点必须澄清:HttpOnly Cookie被很多人误当成"XSS的解药",它其实只是给攻击者偷Cookie增加了障碍。前面说过,XSS脚本根本不需要偷Cookie,它可以直接借用户会话发请求。所以HttpOnly是值得做的加固项,但永远不能替代输出编码。
7.3 为什么"过滤脚本"思路注定失败
回头看那些被XSS打穿的系统,几乎都有一个共同点:安全措施停留在"过滤输入"层面——过滤关键字、过滤标签、过滤事件名。这种思路的致命弱点是,过滤规则永远滞后于浏览器语法。浏览器支持的标签有上百种、事件处理器有上百种、编码组合更是无穷无尽,你拦住了onerror,攻击者换onload;你拦住了onload,攻击者再换onmouseover加autofocus;你封了标签白名单,攻击者还会利用已有的合法标签配合奇特的属性组合。
安全的正确姿态是默认"输入不可信":不信任任何用户可控数据,输出时统统按上下文编码。这样无论攻击者提交什么花哨的Payload,到了页面里它都只是被编码过的普通文本,浏览器永远没有机会把它当代码执行。这也是为什么很多资深安全工程师说:"不要问这个输入能不能过滤,要问这个输出有没有编码。"
最后分享一点我个人的体会。我带新人刷XSS练习平台时,最明显的分水岭不是谁背的Payload多,而是谁先养成"看响应源码、定上下文、测过滤规则"的流程。一开始可能慢,但十关二十关之后,这个流程会内化成一种直觉,看到一段代码就能猜到它的输出点在哪、大概会卡在哪类过滤上。练XSS本质上练的是对浏览器解析机制的理解,机制吃透了,无论以后碰到练习平台还是真实系统的代码审计,你都能用同一套思路解决问题。也提醒一句:所有测试请在自己的授权靶场或自建环境里进行,不要把探索的脚步伸向不属于你的系统——技术是用来理解世界,而不是破坏世界的。