
很多做安全的朋友习惯在漏洞点放一个scriptalert(1)/script弹窗成功就“收获”没弹窗就觉得这里没有 XSS。这个习惯做功能验证还行做深度审计容易漏掉真正的风险。Alert(1)只是探测器不是武器库——真正要回答的问题是用户输入出现在哪里、由谁解释它、后端过滤到底堵在哪一层、前端策略还剩多少可控空间。本文是从我做过的一次授权渗透测试中提炼出来的经验所有演示都基于测试系统或本地靶场重点讲清楚三件事先定位上下文再理解过滤器为什么拦 / 为什么不拦最后把 CSP 策略也纳入 payload 设计的变量清单。如果你已经会弹窗但看不懂为什么换个位置就失效或者总觉得 WAF 是“玄学”这篇文章应该能给你一个相对系统的分析框架。1. 先看输入出现在哪上下文决定一切1.1 同样一段输入放在三个位置结果完全不同很多开发者以为“做了过滤”就安全但过滤是否有效完全取决于输入最终被放到哪里。我见过不少后端把和转义了却不处理引号结果在 HTML 属性里照样被闭合也见过把引号处理了却不处理反斜杠结果在 JavaScript 字符串里被转义逃逸。所以分析 XSS 的第一件事不是猜 payload而是确认注入点属于哪一类渲染上下文。举三个最简单的例子。如果输入出现在 HTML 文本节点div用户名USERDATA/div此时如果没有转义直接提交scriptalert(1)/script就会生效。如果输入出现在标签属性里input valueUSERDATA此时即使被过滤也没关系只要引号没处理就可以构造input value onmouseoveralert(1) x这里的关键不是“有没有 onmouseover”而是“双引号能否提前闭合属性”。如果把同样的输入放到 JS 字符串里script let username USERDATA; /script这时候 HTML 标签和属性都不好使正确思路是闭合字符串script let username ;alert(1);//; /script三个位置三种语法三种完全不同的绕过逻辑。所以我不是先想 payload而是先看输入到底落在哪。1.2 定位输入方向别只看响应内容要看渲染节点我个人的做法是给每个测试点发一个“标记串”而不是一上来就发script。比如zzzb123/b把这个标记串提交到输入点然后在响应页面里搜zzz。用开发者工具看节点树能立刻判断出它是作为纯文本、属性值还是嵌在 JS 字符串里。很多看起来“无效”的 payload其实是把适用于属性位置的 payload 放到了 JS 上下文里或者反过来。这一步还有一个容易被忽略的细节如果输入最终被拼接进script标签内部浏览器对 HTML 实体的解码行为跟文本节点并不完全一样。也就是说后端做了htmlspecialchars或等价转义并不能保证 JS 里安全要看 JS 解析器拿到的是字符流哪一层。用标记串确认上下文比肉眼判断可靠得多。1.3 上下文判断速查表输入位置关键判断点典型构造形态HTML 文本是否被转义script、img/onerror、SVG 标签HTML 属性引号是否被转义 onfocusalert(1) x、 autofocus onfocus...JS 字符串引号、反斜线是否被过滤;alert(1)//、alert(1)URL 属性协议是否被白名单限制javascript:alert(1)、data:text/html,script...把标记串和这张表配合起来基本能覆盖 80% 的常规输入点。2. 绕过过滤先判断过滤器在“处理谁”2.1 最常见的低级差错只过滤了一层没过滤另一层绕过过滤器的核心不是跟它对着干而是看它到底在哪一层做了拦截。比如有些 WAF 只识别了script标签但对svg/onloadalert(1)完全无感有些只拦截了alert这个函数名却放过了eval、Function、setTimeout、location等同样可用于验证的 API。这不是说 WAF 一定很弱而是它面对每一种上下文都要确保所有合法标签和事件都被覆盖这本来就是一件很难做到的事情。所以实际测试时我不会把一个 payload 被拦就当成“没漏洞”而是先拆开看它被拦的层级。被拦截的是字面字符串还是语义如果 WAF 检测的是alert(1)这种固定模式往往用alert(1)加一些无害注释就能绕过如果它检测的是script这种标签特征那就换成事件属性如果它连事件属性都检测那就要看 HTML 解析和编码层有没有缝隙。2.2 编码漏斗让浏览器替你解码绕过过滤的一大思路是找到能够把输入“还原为可执行代码”的解码层。一个很经典的例子是 HTML 实体编码当用户输入被放进 HTML 标签属性值时如果后端只转义了和却没有转义引号那么属性解析器会先做 HTML 实体解码。也就是说提交 onmouseoverquot;这类变形浏览器读到属性值时会先把quot;还原成最终极有可能形成新的属性边界。在 JavaScript 上下文中类似思路可以用 Unicode 转义来实现。比如alert被拦截时window[\u0061lert](1)在部分解析场景下会被还原为alert。当然具体能不能利用取决于字符转义发生的位置不同的 JS 解析阶段差别很大所以我不建议死记这一条而是建议把它当成“过滤器是否覆盖编码层”的验证手段。另一个实用场景是 URL 属性。很多过滤规则只拦了大小写混写的javascript:却忽略了java%0ascript:或者#x6a;avascript:。如果后端没有在 URL 层面做协议白名单浏览器会把 URL 解析后的结果重新交给对应协议处理编码变形就可能被还原。2.3 函数名被拦时的等价替换思路假设你确认了输入点在 HTML 文本节点后端也确实转义了但事件属性里可以塞入 JS而你常用的alert函数名被过滤掉了。此时测试口径要放宽一些因为“XSS 是否存在”不等于“alert 是否可调用”。我通常会在授权测试里换用几种不同形态来做验证// 形态一不弹窗只改页面标题 document.title XSS-1; // 形态二打日志方便在控制台确认执行 console.log(XSS-1); // 形态三触发简单网络请求用同源后台日志确认执行 fetch(/beacon?xss1);这三个载荷不碰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这条策略意味着脚本只能从同源文件加载不允许内联脚本也不允许插件资源。此时页面上如果出现了scriptalert(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 srchttps://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 场景描述某内网后台的“昵称”字段我在一次授权测试里遇到过一个典型例子。某内网后台这里化名“某后台系统”在“个人设置”里有一个昵称字段改完后会被渲染到管理列表页。第一反应当然是提交scriptalert(1)/script结果请求直接被 WAF 拦截响应里返回了[removed]看起来像是“过滤掉了”。这时候如果只按“有没有弹窗”来判断结论就是“无 XSS”。但把这个字段放到不同的输出位置里继续测试情况完全不一样。4.2 第一步抓响应确认输出位置我先把昵称改成zzzb123/b然后刷新列表页。搜索zzz发现昵称没有出现在 HTML 文本中而是被填充到了input value...属性的 value 里。后端转义了和但是并没有转义双引号。同时WAF 对包含script的请求会在入口层直接替换所以前面那个 payload 连进入后端的机会都没有。到这里基本可以确定方向不要在script标签上死磕要利用属性闭合。4.3 第二步用属性事件触发脚本执行WAF 拦了script但没有拦onfocus、autofocus等属性。于是我在授权范围内构造了这样一个输入形态 autofocus onfocusdocument.titleXSS-2 x提交后页面源码变成input value autofocus onfocusdocument.titleXSS-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 会被“误导”有一次我在开发者工具里看到输入被显示成了b123/b的加粗节点就以为是 HTML 注入成功结果截图里根本没有加粗效果。后来才发现开发者工具在“元素”面板里会把 HTML 实体解码后展示而真实页面渲染时它仍然只是文本。所以判断是否真正注入不能只依赖开发者工具的展示要看节点的类型、截图渲染结果、网络请求是否发出去。这个细节让很多看起来是“漏洞”的现象变成了误报。6.3 团队成员协作时会写“复现步骤”比会写 payload 更重要做红队或者渗透测试最怕的就是报告里只写“发现 XSSpayload 为scriptalert(1)/script”。这句话对于修复几乎没有帮助。我会要求每个 XSS 发现都附带“输入参数 输出位置 上下文确认方式 稳定 poC 截图/请求日志”。这样做有两个好处开发能快速定位问题回归测试时也能照着步骤确认是否修复。真正的高价值从来不只是那个会弹窗的标签而是你把它背后的上下文、过滤逻辑、策略关系都讲清楚了。我个人在实战里最后的体会是alert(1)更像是一道入门题但 XSS 的复杂性在于“上下文”和“策略”两个词。把输入位置判断准了把 CSP 的允许边界理清了很多看起来无解的注入点都能找到出口。希望这篇经验记录能帮你少走一点弯路。