XSS,也就是跨站脚本攻击,本质上是浏览器把不可信输入解释成了代码。
漏洞通常出现在“用户可以写,页面又会展示”的位置,例如搜索框、评论区、昵称、个人资料和消息提醒。只要输入进入 HTML、属性、URL 或 JavaScript 上下文,而输出处理不正确,就可能越过数据边界。
本文不提供可直接用于攻击的 XSS 载荷。安全验证应在授权环境中使用无害标记,重点证明执行条件,而不是窃取数据或影响真实用户。
一、XSS 成立需要什么条件
一个 XSS 问题通常同时满足以下条件:
- 不可信输入能够进入页面。
- 输入落在浏览器会解释的位置。
- 输出编码或过滤没有匹配当前上下文。
“用户可控”不只包括 URL 参数,还包括:
- 表单字段。
- Cookie 和请求头。
- 数据库中的历史内容。
- 文件上传后的文件名。
- 第三方接口返回的数据。
- WebSocket 消息。
postMessage事件。- 前端路由和 URL 片段。
风险点不在于输入来自哪里,而在于它最终被放进了什么上下文。
二、常见类型
2.1 反射型 XSS
反射型 XSS 中,请求参数被服务端立即写回响应页面。恶意参数通常不在服务器长期保存,需要诱导用户访问包含该参数的链接。
典型链路:
请求参数
-> 服务端模板
-> 未正确编码的 HTML
-> 浏览器执行
只在当前用户的浏览器中触发,并不意味着风险低。攻击者仍可能借此读取当前页面数据或诱导用户操作。
2.2 存储型 XSS
存储型 XSS 会把不可信内容保存到数据库、缓存或文件中,其他用户访问页面时再次输出。
典型场景:
- 评论内容。
- 用户昵称和签名。
- 工单描述。
- 文件名称。
- 导入的数据。
- 富文本编辑器。
它的影响范围可能更大,因为内容可能在多个页面和多个用户之间传播。
2.3 DOM 型 XSS
DOM 型 XSS 的主要处理发生在浏览器端。服务器返回的 HTML 可能完全正常,但前端脚本把不可信数据写入了危险位置。
常见数据源包括:
location.searchlocation.hashdocument.referrerwindow.namepostMessage事件数据- 本地存储和 Session Storage
常见危险写入点包括:
innerHTMLouterHTMLdocument.write- 动态创建事件属性
- 把字符串当作代码执行
- 把外部内容插入 SVG 或 iframe
DOM 型 XSS 不能只靠服务端 HTML 编码解决。前端也必须根据最终插入位置进行安全处理。
三、输出上下文决定编码方式
“统一替换几个特殊字符”不是完整的 XSS 防御。不同上下文需要不同编码方式。
3.1 HTML 文本
用户内容进入普通文本节点时,应把 &、<、> 等字符转义,避免被解释成标签。
后端示例:
$safe = htmlspecialchars(
$input,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
该函数适合普通 HTML 文本和大部分属性值,但不能直接解决 JavaScript、CSS 和 URL 中的所有问题。
3.2 HTML 属性
属性需要同时处理引号和上下文。更安全的做法是:
- 尽量用模板引擎自动转义。
- 属性值始终使用引号包围。
- 不把用户输入拼接到事件属性。
- 对 URL 属性执行协议白名单。
例如,用户提交的链接不应允许危险协议或外部脚本执行。
3.3 JavaScript 上下文
不要在脚本字符串中直接插入未经处理的用户输入。更好的方式是:
- 服务端返回结构化 JSON。
- 前端通过安全 API 读取。
- 使用
textContent写入文本。 - 不把用户输入传给代码执行函数。
安全文本写入示例:
const titleElement = document.querySelector("#post-title");
titleElement.textContent = postTitle;
textContent 会按文本处理内容,不会把其中的标签解析为 HTML。
3.4 URL 上下文
URL 参数需要先编码,并验证协议。允许的协议应明确写在白名单中。
不要直接依赖客户端提供的跳转地址。开放重定向与 XSS 组合后可能扩大影响。
3.5 富文本
富文本编辑器通常必须允许一部分 HTML,因此不能简单依赖普通文本编码。应采用:
- 结构化编辑器输出。
- 服务端 HTML 清洗。
- 标签和属性允许列表。
- 禁止脚本、事件属性和危险 URL。
- 定期测试清洗规则。
只用黑名单删除 <script> 远远不够。
四、过滤与黑名单为什么不可靠
黑名单通常假设攻击者只会使用几种固定写法,但浏览器和 HTML 解析器支持的语法更复杂。
常见问题包括:
- 标签或属性的大小写变化。
- 多重编码。
- 被过滤后重新拼接出的危险结构。
- 事件属性和资源地址。
- 不同浏览器对异常 HTML 的容错。
- 输入经过多个代理和组件重复解码。
正确方向不是在输入中寻找所有“坏词”,而是:
- 明确允许的数据格式。
- 在正确输出位置进行编码。
- 对富文本做结构化清洗。
- 用浏览器安全策略作为第二层防线。
五、前端安全 API
5.1 DOM 写入
优先使用安全文本接口:
element.textContent = value;
element.setAttribute("title", value);
只有在内容已经过可信清洗时,才考虑插入 HTML。不要直接使用:
element.innerHTML = untrustedValue;
5.2 事件处理
应通过 addEventListener 绑定函数:
button.addEventListener("click", handleClick);
不要把用户字符串当作事件代码,也不要根据不可信数据动态生成属性。
5.3 消息事件
使用 postMessage 时应验证:
- 消息来源
origin。 event.source是否符合预期。- 消息字段结构和类型。
- 消息是否只包含当前页面需要的数据。
不要接收任意来源的消息后直接写入页面或执行操作。
六、浏览器侧纵深防御
6.1 Cookie 属性
会话 Cookie 应设置:
HttpOnly:降低脚本读取 Cookie 的风险。Secure:只通过 HTTPS 发送。SameSite:限制跨站请求携带范围。
这些属性不能修复 XSS,但可以降低会话数据泄漏和跨站请求风险。
6.2 内容安全策略
CSP 可以限制脚本来源和内联脚本执行。合理的策略通常包括:
- 默认只允许明确列出的脚本源。
- 避免不必要的
unsafe-inline和unsafe-eval。 - 对第三方脚本单独评估。
- 使用 nonce 或 hash 管理必要的内联脚本。
- 先以报告模式观察,再逐步收紧。
CSP 是重要防线,但不能替代输出编码。如果应用允许来自同一来源的危险脚本,CSP 仍可能被绕过。
6.3 安全响应头
可结合以下响应头降低浏览器攻击面:
Content-Security-PolicyX-Content-Type-Options: nosniffReferrer-Policy- 合理的
Permissions-Policy
是否使用 X-Frame-Options 或 CSP 的 frame-ancestors,取决于页面是否需要被嵌入。
6.4 敏感操作保护
对于修改密码、转账、删除数据等操作,应:
- 要求有效会话。
- 校验 CSRF 防护令牌。
- 对高风险操作重新认证。
- 记录审计日志。
- 不把用户角色和资源归属交给前端决定。
七、工具在测试中的作用
BeEF 等浏览器安全测试工具可以观察 XSS 触发后的浏览器行为。这类工具只能在明确授权的测试环境中使用。
在合法测试中,应优先使用无害标记证明执行条件,例如写入一个独立测试页面可见的静态提示。不要读取真实 Cookie、监听键盘、截屏或诱导用户。
测试报告应说明:
- 漏洞所在的输入和输出位置。
- 触发时需要的前置条件。
- 是否可持久化。
- 影响范围。
- 修复建议。
- 修复后的复测结果。
八、检测与监控
建议记录:
- 输入来源和输出页面。
- 用户、会话和请求 ID。
- 被清洗规则拦截的字段。
- CSP 报告。
- 异常跳转和动态脚本创建。
- 大量相似请求或自动化探测。
WAF 日志可以提供线索,但不应作为唯一修复手段。XSS 的根因仍然在输出上下文和数据流。
九、防御清单
- 所有输入都有明确的输出位置。
- 编码方式与 HTML、属性、URL 或 JavaScript 上下文匹配。
- 富文本使用结构化清洗和允许列表。
- 前端优先使用
textContent等安全写入方式。 - 不执行来自 URL、存储或消息事件的任意字符串。
- 会话 Cookie 启用
HttpOnly、Secure和SameSite。 - 配置并持续维护 CSP。
- 第三方脚本经过安全评估。
- 敏感操作有 CSRF 防护和重新认证。
- 前端过滤没有替代服务端输出编码。
- 安全测试使用无害验证标记,不影响真实用户。
十、小结
XSS 的防御重点不是删除某个标签,而是理解数据从输入到浏览器执行的完整路径。
同一条用户数据进入 HTML 文本、属性、URL 和 JavaScript 时,需要不同的处理方式。只有在正确上下文完成编码,再叠加 CSP、Cookie 属性和安全的前端 API,才能形成稳定的纵深防御。