很多做安全的朋友习惯在漏洞点放一个<script>alert(1)</script>,弹窗成功就“收获”,没弹窗就觉得这里没有 XSS。这个习惯做功能验证还行,做深度审计容易漏掉真正的风险。Alert(1)只是探测器,不是武器库——真正要回答的问题是:用户输入出现在哪里、由谁解释它、后端过滤到底堵在哪一层、前端策略还剩多少可控空间。本文是从我做过的一次授权渗透测试中提炼出来的经验,所有演示都基于测试系统或本地靶场,重点讲清楚三件事:先定位上下文,再理解过滤器为什么拦 / 为什么不拦,最后把 CSP 策略也纳入 payload 设计的变量清单。如果你已经会弹窗,但看不懂为什么换个位置就失效,或者总觉得 WAF 是“玄学”,这篇文章应该能给你一个相对系统的分析框架。
1. 先看输入出现在哪:上下文决定一切
1.1 同样一段输入,放在三个位置结果完全不同
很多开发者以为“做了过滤”就安全,但过滤是否有效,完全取决于输入最终被放到哪里。我见过不少后端把<和>转义了,却不处理引号,结果在 HTML 属性里照样被闭合;也见过把引号处理了,却不处理反斜杠,结果在 JavaScript 字符串里被转义逃逸。所以分析 XSS 的第一件事,不是猜 payload,而是确认注入点属于哪一类渲染上下文。
举三个最简单的例子。如果输入出现在 HTML 文本节点:
<div>用户名:USERDATA</div>此时如果没有转义<,直接提交<script>alert(1)</script>就会生效。如果输入出现在标签属性里:
<input value="USERDATA">此时即使<被过滤也没关系,只要引号没处理,就可以构造:
<input value="" onmouseover="alert(1)" x="">这里的关键不是“有没有 onmouseover”,而是“双引号能否提前闭合属性”。如果把同样的输入放到 JS 字符串里:
<script> let username = "USERDATA"; </script>这时候 HTML 标签和属性都不好使,正确思路是闭合字符串:
<script> let username = "";alert(1);//"; </script>三个位置,三种语法,三种完全不同的绕过逻辑。所以我不是先想 payload,而是先看输入到底落在哪。
1.2 定位输入方向:别只看响应内容,要看渲染节点
我个人的做法是,给每个测试点发一个“标记串”,而不是一上来就发<script>。比如:
zzz'"<b>123</b>把这个标记串提交到输入点,然后在响应页面里搜zzz。用开发者工具看节点树,能立刻判断出它是作为纯文本、属性值,还是嵌在 JS 字符串里。很多看起来“无效”的 payload,其实是把适用于属性位置的 payload 放到了 JS 上下文里,或者反过来。
这一步还有一个容易被忽略的细节:如果输入最终被拼接进<script>标签内部,浏览器对 HTML 实体的解码行为跟文本节点并不完全一样。也就是说,后端做了htmlspecialchars或等价转义并不能保证 JS 里安全,要看 JS 解析器拿到的是字符流哪一层。用标记串确认上下文,比肉眼判断可靠得多。
1.3 上下文判断速查表
| 输入位置 | 关键判断点 | 典型构造形态 |
|---|---|---|
| HTML 文本 | <是否被转义 | <script>、<img/onerror>、SVG 标签 |
| HTML 属性 | 引号是否被转义 | " onfocus=alert(1) x="、" autofocus onfocus="... |
| JS 字符串 | 引号、反斜线是否被过滤 | ";alert(1)//、'+alert(1)+' |
| URL 属性 | 协议是否被白名单限制 | javascript:alert(1)、data:text/html,<script>... |
把标记串和这张表配合起来,基本能覆盖 80% 的常规输入点。
2. 绕过过滤:先判断过滤器在“处理谁”
2.1 最常见的低级差错:只过滤了一层,没过滤另一层
绕过过滤器的核心不是跟它对着干,而是看它到底在哪一层做了拦截。比如有些 WAF 只识别了<script>标签,但对<svg/onload=alert(1)>完全无感;有些只拦截了alert这个函数名,却放过了eval、Function、setTimeout、location等同样可用于验证的 API。这不是说 WAF 一定很弱,而是它面对每一种上下文都要确保所有合法标签和事件都被覆盖,这本来就是一件很难做到的事情。
所以实际测试时,我不会把一个 payload 被拦就当成“没漏洞”,而是先拆开看它被拦的层级。被拦截的是字面字符串,还是语义?如果 WAF 检测的是alert(1)这种固定模式,往往用alert(1)加一些无害注释就能绕过;如果它检测的是<script这种标签特征,那就换成事件属性;如果它连事件属性都检测,那就要看 HTML 解析和编码层有没有缝隙。
2.2 编码漏斗:让浏览器替你解码
绕过过滤的一大思路,是找到能够把输入“还原为可执行代码”的解码层。一个很经典的例子是 HTML 实体编码:当用户输入被放进 HTML 标签属性值时,如果后端只转义了<和>,却没有转义引号,那么属性解析器会先做 HTML 实体解码。也就是说,提交" onmouseover=""这类变形,浏览器读到属性值时会先把"还原成",最终极有可能形成新的属性边界。
在 JavaScript 上下文中,类似思路可以用 Unicode 转义来实现。比如alert被拦截时,window['\u0061lert'](1)在部分解析场景下会被还原为alert。当然,具体能不能利用,取决于字符转义发生的位置,不同的 JS 解析阶段差别很大,所以我不建议死记这一条,而是建议把它当成“过滤器是否覆盖编码层”的验证手段。
另一个实用场景是 URL 属性。很多过滤规则只拦了大小写混写的javascript:,却忽略了java%0ascript:,或者javascript:。如果后端没有在 URL 层面做协议白名单,浏览器会把 URL 解析后的结果重新交给对应协议处理,编码变形就可能被还原。
2.3 函数名被拦时的等价替换思路
假设你确认了输入点在 HTML 文本节点,后端也确实转义了<,但事件属性里可以塞入 JS,而你常用的alert函数名被过滤掉了。此时测试口径要放宽一些,因为“XSS 是否存在”不等于“alert 是否可调用”。我通常会在授权测试里换用几种不同形态来做验证:
// 形态一:不弹窗,只改页面标题 document.title = 'XSS-1'; // 形态二:打日志,方便在控制台确认执行 console.log('XSS-1'); // 形态三:触发简单网络请求,用同源后台日志确认执行 fetch('/beacon?xss=1');这三个载荷不碰alert,但都能证明“脚本在用户浏览器里被执行了”。如果测试目标是验证数据读取风险,可以从同源接口拉取一个只有当前账号能看到的信息,再把它写入页面某个可见元素里,这样既能证明影响范围,又不会越界去碰其他用户的数据,适合在授权范围内做闭环验证。
这里要强调一下:绕过过滤本身不是目的,目的是判断“攻击者还能控制多少浏览器行为”。能用fetch,就比只弹窗口更接近真实利用场景;能被 CSP 允许的网络请求,也比只做 DOM 改写的风险更大。报告里真正有价值的信息,是这个输入点能走到哪一步,而不是能不能弹一个窗。
2.4 从绕过到证明:稳定的探测载荷设计
稳定的探测载荷应该满足两个条件:第一,不容易被浏览器内置的 XSS 过滤器误伤;第二,测试人员能在响应里快速看到执行痕迹。弹窗有一个很尴尬的问题,就是某些浏览器对window.alert会追加风险提示,干扰自动化截图;无头浏览器里弹窗还会挂住进程。所以我现在更多用“非阻塞”的证明方式。
比如把页面标题改成XSS-1,或者把页面背景色改掉。这类副作用很容易在自动化测试里截屏留证,而且不会产生大面积弹窗干扰。报告里贴出的证据也更直观:标题变了,脚本执行了,影响点讲清楚,开发一改就明白。
3. CSP 不是万能防线:理解它的弱点才能正确配置
3.1 先把 CSP 策略翻译成可测试的“允许集合”
很多团队部署了 CSP 之后就觉得“XSS 堵死了”,但实际测试中会发现,策略写得好不好,跟“有没有写”是两回事。简单理解,CSP 就是告诉浏览器“哪些来源的脚本可以执行、哪些内联行为允许、哪些协议允许”。它不会让注入点消失,只会让注入后的代码更难执行。所以我们做测试时,会把 CSP 响应头当成一个“允许集合”来读。
比如:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'这条策略意味着脚本只能从同源文件加载,不允许内联脚本,也不允许插件资源。此时页面上如果出现了<script>alert(1)</script>这种内联块,会被浏览器直接拦掉。但注意,它并没有解决“属性事件”的问题。如果策略里额外允许了'unsafe-inline',那事件属性里的 JS 仍然能执行;如果允许了'unsafe-eval',那么eval、Function这类动态执行方式也仍然能奏效。
3.2 从实战视角看 CSP 的“软肋”
从防御者视角看,CSP 最常见的软肋有四个。
第一,为了兼容老旧页面,'unsafe-inline'被长期保留。这种情况下,只要注入点是 HTML 文本或属性事件,CSP 等于形同虚设。我们遇到过不少系统,外部看 CSP 头很完整,仔细读才发现script-src后面跟着'unsafe-inline'。
第二,object-src没设成none。虽然现代浏览器对 Flash、Java 这类插件的支持越来越少,但企业内部系统仍然可能有旧的 ActiveX 控件或浏览器插件。一旦存在插件且不在 CSP 限制内,攻击面就会多出一块很难用常规 CSP 覆盖的领域。
第三,源白名单里包含“可被利用的 CDN 或 JSONP 接口”。假设策略允许了某个静态资源域,而该域上存在一个能输出用户可控参数的 JSONP 接口,那么攻击者可以把<script src="https://allowed.example/jsonp?callback=...">这种请求变成脚本加载通道。这属于“可信源本身的妥协”,也是为什么防御端在做源白名单时需要格外谨慎。
第四,nonce 重复或可预测。使用 nonce 策略本身没问题,问题是有些后端把 nonce 放在了静态缓存页面里,或者同一个 nonce 会复用很长时间。一旦攻击者能在页面注入一个带有合法 nonce 的脚本标签,CSP 策略就失去了意义。所以 nonce 必须是每次响应都随机生成,并且只能绑定到原本合法的内联脚本上。
3.3 最小演示:策略里留一个小口子会怎样
我经常在内部培训里用一个极简例子说明“策略与上下文的关系”。假设页面 CSP 为:
Content-Security-Policy: script-src 'unsafe-inline'此时 HTML 文本节点里的<script>内联脚本会被执行;如果 CSP 改成:
Content-Security-Policy: script-src 'self'那内联脚本就执行不了,这时候用javascript:协议或<script src="同源文件">还能不能利用,就要看是否存在文件上传、同源路径可控、JSONP 接口等问题。
这个演示并不是在教人去打真实系统,而是告诉大家:CSP 的每一项配置,都会影响 payload 的可行集合。防御端在测试 CSP 时,不能只看 header,要看页面里真实允许了哪些路径;攻击端在做授权测试时,也必须把 CSP 列入“变量环境”,否则可能因为一个策略头就得出错误的判断。
4. 一次授权测试的完整思路:从输入点到最终证据
4.1 场景描述:某内网后台的“昵称”字段
我在一次授权测试里遇到过一个典型例子。某内网后台(这里化名“某后台系统”)在“个人设置”里有一个昵称字段,改完后会被渲染到管理列表页。第一反应当然是提交<script>alert(1)</script>,结果请求直接被 WAF 拦截,响应里返回了[removed],看起来像是“过滤掉了”。
这时候如果只按“有没有弹窗”来判断,结论就是“无 XSS”。但把这个字段放到不同的输出位置里继续测试,情况完全不一样。
4.2 第一步:抓响应,确认输出位置
我先把昵称改成zzz'"<b>123</b>,然后刷新列表页。搜索zzz发现,昵称没有出现在 HTML 文本中,而是被填充到了<input value="...">属性的 value 里。后端转义了<和>,但是并没有转义双引号。同时,WAF 对包含<script的请求会在入口层直接替换,所以前面那个 payload 连进入后端的机会都没有。
到这里,基本可以确定方向:不要在<script>标签上死磕,要利用属性闭合。
4.3 第二步:用属性事件触发脚本执行
WAF 拦了<script,但没有拦onfocus、autofocus等属性。于是我在授权范围内构造了这样一个输入形态:
" autofocus onfocus="document.title='XSS-2'" x="提交后页面源码变成:
<input value="" autofocus onfocus="document.title='XSS-2'" x="">页面加载时输入框自动聚焦,触发onfocus,执行了document.title赋值。没有使用任何严格被拦截的标签,也没有弹窗,但脚本确实执行了。这一步证明了“用户输入的引号可以被利用来闭合属性,并把事件处理器挂到合法标签上”。
4.4 第三步:验证影响范围,留下证据
为了确认影响范围,我又在测试账号下尝试了同源的简单数据读取,把当前账号的昵称拼接到一个可观测的输出里,再配合截图和响应报文存档,完成闭环证据链。整个过程没有触碰其他账号数据,也没有对外部系统发起任何扫描,完全限定在授权目标和测试账号范围里。
这个案例的关键不是 payload 本身有多“高级”,而是分析路径很清晰:先判断输出位置,再分析过滤点,最后选择符合 CSP 和上下文约束的执行方式。很多时候所谓“高级”,只是比盲目撞 payload 多做了两步上下文判断。
4.5 给测试报告的三个要件
我写测试报告时,这个类型的问题至少会包含三样东西。第一,请求和响应原文,标明哪个参数、哪个位置发生了闭合;第二,浏览器控制台或页面截图上能看到的执行痕迹;第三,修复建议,不是简单说“过滤输入”,而是说清楚当前输出点缺了属性值转义,应该补哪一种上下文编码。这样的报告,开发和运维拿去就能直接改,而不是反复问“到底怎么复现”。
5. 修复与自检:别把 Bug 修成“看着像修”
5.1 五个常被遗忘的编码位置
很多修复方案只处理了“HTML 文本节点”和“标签属性”两层,但 XSS 还经常出现在 CSS、URL、JavaScript 字符串、JSON 数据等位置。只对其中一个位置编码,可能在另一个位置还是有缝。举例来说,后端如果对昵称统一做了 HTML 实体编码,那么把它放进<script>字符串里时,有些场景仍然可能被 JS 解析器二次解码并执行。所以修复一定要按“输出上下文”分门别类:
| 输出位置 | 该用什么方式处理 |
|---|---|
| HTML 文本 | HTML 实体编码,至少转义<、>、& |
| HTML 属性 | 属性值特殊字符转义,重点处理引号 |
| JavaScript 字符串 | JS 字符串转义,重点处理引号、反斜杠、换行 |
| URL 属性 | 协议白名单,禁止非 http/https 协议 |
| JSON / 富文本 | 使用针对该格式的序列化转义,或做安全过滤白名单 |
5.2 CSP 配置的实际建议
修复时如果要上 CSP,我建议至少做到这几步:第一,把script-src里的'unsafe-inline'和'unsafe-eval'都去掉,改用 nonce 或 hash 机制放行固定的内联脚本;第二,补上object-src 'none'和base-uri 'none';第三,所有外部脚本源都要做人工复核,不要把用户可控内容的域名放进白名单。
做完配置后,一定要用真实流量测试一遍,而不是只看响应头存在。很多系统会经过网关、CDN 或中间件多层转发,某一层可能追加了一个宽松的 CSP 头,浏览器会合并同名字段,结果你以为自己配得很严,实际生效的是宽松版。测试时用浏览器控制台把所有<script>的执行结果过一遍,比单看 header 更可靠。
5.3 快速自检表
修复完成后,我一般会过一遍下面的清单:
- 输出点是否已经按上下文分类处理,而不是全局统一编码?
- 属性里的引号、JS 字符串里的反斜杠和换行是否单独验证过?
- WAF 拦截规则拿掉之后,后端本身是否依然安全?
- CSP 的 nonce 是否每次响应都随机变化?源白名单是否最小化?
- 是否测试了
javascript:、data:等非常规协议? - 是否存在同源 JSONP 接口可以被引为脚本源?
这六项如果在内部测试环境都能过,通常比单纯加一个 WAF 规则要扎实得多。
6. 实战经验与最后一条建议
6.1 我的调试小技巧:优先用可见副作用,而不是弹窗
弹窗作为 PoC 最大的问题是“噪音大、干扰多”。在控制台中验证还好,一旦放到无头浏览器或者自动截图的流程里,弹窗反而容易打断流程。我会优先用document.title、console.log、请求日志这类副作用来验证。比如在测试环境,把标题改成带有特殊标记的字符串,自动化脚本能直接判断是否执行,截图也更干净。
6.2 踩过的坑:用开发者工具看 DOM 会被“误导”
有一次我在开发者工具里看到输入被显示成了<b>123</b>的加粗节点,就以为是 HTML 注入成功,结果截图里根本没有加粗效果。后来才发现,开发者工具在“元素”面板里会把 HTML 实体解码后展示,而真实页面渲染时它仍然只是文本。所以判断是否真正注入,不能只依赖开发者工具的展示,要看节点的类型、截图渲染结果、网络请求是否发出去。这个细节让很多看起来是“漏洞”的现象变成了误报。
6.3 团队成员协作时,会写“复现步骤”比会写 payload 更重要
做红队或者渗透测试,最怕的就是报告里只写“发现 XSS,payload 为<script>alert(1)</script>”。这句话对于修复几乎没有帮助。我会要求每个 XSS 发现都附带“输入参数 + 输出位置 + 上下文确认方式 + 稳定 poC + 截图/请求日志”。这样做有两个好处:开发能快速定位问题;回归测试时也能照着步骤确认是否修复。真正的高价值,从来不只是那个会弹窗的标签,而是你把它背后的上下文、过滤逻辑、策略关系都讲清楚了。
我个人在实战里最后的体会是:alert(1)更像是一道入门题,但 XSS 的复杂性在于“上下文”和“策略”两个词。把输入位置判断准了,把 CSP 的允许边界理清了,很多看起来无解的注入点都能找到出口。希望这篇经验记录,能帮你少走一点弯路。