资讯动态

DVWA实战:从反射型到存储型XSS的防御指南

发布时间:2026/10/21 16:37:06 来源:尧图企业网站定制
第一次在本地把DVWA三个等级的XSS页面都跑通时我最大的感受不是“我会测了”而是“如果这是我自己的项目我敢让它上线吗”说实话DVWA这个靶场之所以值得反复练就是因为它的XSS题目设计得很贴近真实代码里常见的坑低级别就是裸奔输出中等级别想过滤但没过滤干净高级别以为加了一堆黑名单就安全了结果还是能被浏览器解析特性绕过去。这篇指南不是教你怎么打而是教你怎么防。我会拿DVWA的反射型XSS和存储型XSS做实验场把DOMPurify、escape-html、Handlebars、Vue、React这5类库/框架的防御方式挨个实战一遍最后再叠加上CSP和Trusted Types这道平台级防线。如果你正在学Web安全或者你是前端工程师想在项目里少留几个安全坑这篇文章就是为你准备的。1. 把DVWA的XSS页面当成防御练习场先搞懂漏洞在哪1.1 三个等级背后的三种典型防御失误DVWA里的XSS模块分成反射型、存储型和DOM型每个又分低、中、高三个安全级别。很多人刷低级别时觉得“这太简单了”但真正到中高级别你会发现每一级都代表了一类真实项目里反复出现的防御失误。低级别的反射型XSS服务端代码几乎等于直接把用户输入拼进HTML响应里连基础的HTML实体编码都没做。这种代码在十几年前的留言板、搜索页里大量存在现在也偶尔能在内部系统里看到。中等级别开始有过滤了但用的是最原始的黑名单方式比如把script字符串直接替换成空。这时候只要测试载荷里的大小写变一下或者换成img加事件属性过滤就形同虚设。黑名单的问题在于你永远不可能列全所有能触发解析的标签和属性攻击者只需要一个漏网之鱼。高级别看起来严谨一些用正则把常见危险标签和on*事件都过滤了一轮。但浏览器解析HTML的时候有太多兼容性逻辑比如某些标签会吃掉后续内容重新解析编码方式能被识别成标签的一部分这些都不是靠黑名单正则能根治的。所以这三个等级其实对应的是同一个结论只做输入侧过滤、不做输出侧编码必然会被绕过。1.2 防御必须分层不能只靠一份名单XSS的根因不是“用户输入了恶意东西”而是“不可信的数据被放到了会被当成HTML执行的位置”。同样一段字符串放到textContent里是纯文本放到innerHTML里就可能被解析成节点放到href属性里就可能被拼出javascript:协议放到script标签里就变成代码。所以真正的防御思路是分层的输入校验解决“数据不该长这样”的问题输出编码解决“数据显示成文本而不是代码”的问题CSP解决“即使有代码浏览器也不该执行未知脚本”的问题。前端框架的默认转义解决的是一部分输出编码DOMPurify解决的是你必须允许一部分富文本时的清理Trusted Types解决的是从源头限制不安全的DOM操作入口。我经常用一个生活类比输入校验是小区门口查证件输出编码是告诉你每个房间的门锁怎么拧CSP是就算有人混进来了每层楼还有保安。单独某一层都可能被针对但叠起来之后想穿透的成本就高到攻击者不愿意碰了。2. 5种安全JavaScript库与框架各自解决哪段链路2.1 DOMPurify给脏HTML做手术的工具DOMPurify是目前用得最广的前端HTML净化库核心能力是拿一段可能是脏的HTML字符串经过浏览器DOM解析后再把危险节点剔除最后返回干净的HTML。它最妙的点在于不是用正则去匹配“黑名单”而是先让浏览器真正解析这段字符串再在白名单基础上做遍历过滤。为什么正则不好使而DOMPurify好使因为浏览器已经把不确定的解析结果变实了DOMPurify操作的是解析后的节点树而不是字符串。比如某些标签被浏览器错误闭合后产生的新节点DOMPurify能看到正则看不到。实际用的时候我习惯显式传入配置而不是直接用默认值const cleanHTML DOMPurify.sanitize(dirtyHTML, { ALLOWED_TAGS: [p, strong, em, a, ul, ol, li, br], ALLOWED_ATTR: [href, title, target], ALLOWED_URI_REGEXP: /^(?:https?|ftp|mailto):/i });这段配置的含义是只允许极少数排版标签只保留href等必要属性并且href的目标协议只允许http、https、ftp、mailto。这样就算攻击者塞了javascript:也不会被执行。2.2 escape-html最朴素的输出编码工具如果说DOMPurify是做手术那escape-html就是打疫苗。它是一个很小的编码函数把字符串里的 转成对应的HTML实体。不要小看这几个字符的转换XSS能产生执行效果的源头几乎都依赖这几个特殊字符被浏览器识别成标签、属性或脚本。如果你渲染的内容是纯文本根本不需要富文本那escape-html是性价比最高的选择。因为它足够简单规则足够明确不依赖DOM在任何环境都能跑import escapeHtml from escape-html; const safeName escapeHtml(userName); document.getElementById(welcome).innerHTML pHello, ${safeName}/p ;用的时候有一个细节要记住escape-html只是HTML上下文的编码如果字符串要放进href、src等URL属性光做HTML实体编码不够还要针对URL协议做校验。比如http://变不变实体都能被浏览器识别真正的风险是javascript:伪协议。2.3 Handlebars模板层的默认转义机制Handlebars是一个历史比较长的模板引擎它有一个设计得很早的安全特性双大括号{{name}}默认对输出做HTML转义。模板引擎在生成字符串时就把转成lt;所以注入到模板里的恶意字符串最多显示成文本不会变成节点。这里必须警惕的是Handlebars的三重大括号{{{name}}}它的作用就是输出不转义的原始HTML。很多被绕过的项目都是因为开发者图省事把“这里可能要显示富文本”写成了三重大括号。如果你确实需要输出富文本那就不应该直接输出原始字符串而是先经过DOMPurify再放进模板p{{comment}}/pconst template Handlebars.compile( document.getElementById(comment-tpl).innerHTML ); const html template({ comment: DOMPurify.sanitize(comment.content) });Handlebars还支持注册自定义helper比如一个safeHtmlhelper内部强制走DOMPurify。这样团队协作时模板里只会出现明确表示“这是净化后HTML”的地方才允许输出富文本其他位置全是默认转义。2.4 Vue.js默认转义与v-html的边界Vue是我日常用得最多的框架。只要用{{ }}插值或者在元素上用v-text、:绑定文本内容Vue都会把数据当成纯文本处理任何HTML字符都会被转义。这是设计层面内置的不需要开发者额外操作。真正容易出问题的是v-html。v-html会把字符串当作真实HTML插入DOM如果数据来源是用户可控内容这几乎等于把XSS的大门重新打开。我在代码审查里见过太多次v-html被滥用比如后端返回了一段包含高亮标签的文本前端就想当然地v-html了。如果你不得不用v-html必须保证数据先经过DOMPurifydiv v-htmlsafeContent/divcomputed: { safeContent() { return DOMPurify.sanitize(this.rawContent, { USE_PROFILES: { html: true } }); } }还有个反直觉的点v-html里的内容即使经过净化也可能遇到DOM clobbering一类的问题。所以更稳妥的方案是优先用v-text或插值只有业务明确需要富文本展示时才走净化管线。2.5 ReactJSX转义与dangerouslySetInnerHTML的警告React在类库层面做了很多安全设计。JSX里写{user.name}React会默认转义。React会遍历渲染产生的每个字符串节点把字符串转成文本节点而不是HTML节点。这在绝大多数场景下已经挡住了反射型XSS。但React有个名字吓人的接口dangerouslySetInnerHTML。谁用都知道它是危险的可它依然会频繁出现在已经上线的项目里。常见来源包括后端CMS返回HTML片段个性化推荐组件需要插入广告脚本富文本编辑器输出。看到这些场景就该拉响警报。如果一定要用推荐封装成一个带净化逻辑的组件而不是直接在业务代码里散落dangerouslySetInnerHTMLfunction SafeHtml({ html }) { const clean useMemo(() { return DOMPurify.sanitize(html, { ALLOWED_TAGS: [b, i, em, strong, a, p], ALLOWED_ATTR: [href, title] }); }, [html]); return div dangerouslySetInnerHTML{{ __html: clean }} /; }这样团队看到的是组件名SafeHtml而不是一个需要使用者自己判断安全性的底层危险接口。把安全决策收敛到一处比在几十个页面里分别判断要可靠得多。2.6 配一个平台级防线Trusted Types CSP前面5个方案属于代码侧的防御还有一个浏览器原生机制值得单独说Trusted Types。它不算库也不是框架出镜率最低但防御效果最狠。启用Trusted Types后浏览器会拒绝任何字符串直接赋给innerHTML、document.write()这类危险的DOM sink必须经过一个白名单策略生成TrustedHTML才能赋值。配合CSP响应头Content-Security-Policy: require-trusted-types-for script; trusted-types dvwa-default;这段策略告诉浏览器所有脚本执行都必须过CSP且所有传入HTML sink的值必须是dvwa-default策略产生的东西。到了这步就算攻击者把payload送进了页面只要没有注册过的策略生成TrustedHTMLDOM操作直接抛错。3. 反射型XSSDVWA低中高三级修复实录3.1 修复低级别后端输出编码前端textContent低级别反射型XSS最常见的样子是把用户输入的name参数直接拼进页面。模拟出来的问题代码如下?php $name $_GET[name]; echo preHello, . $name . /pre; ?要修复第一件事就是后端做输出编码。用PHP的htmlspecialchars带上ENT_QUOTES和明确字符集?php $name $_GET[name]; echo preHello, . htmlspecialchars($name, ENT_QUOTES, UTF-8) . /pre; ?但这只解决了服务端渲染纯文本的情况。如果页面里的内容要通过前端JS更新我建议直接用textContent而不是innerHTML。同样的用户输入用textContent赋值时浏览器不会解析任何标签这是最不容易写错的DOM APIconst params new URLSearchParams(window.location.search); const name params.get(name) || ; document.getElementById(welcome).textContent name;不要觉得“我前端已经用textContent了后端是不是不用转义”。前后端都可能被调用防御属于整条链路的责任少一环都可能在另一个入口漏风。3.2 修复中等级别别用黑名单直接白名单校验DVWA中等级别的过滤逻辑是发现script就替换成空。我在本地测试时用大小写混合、用img标签配事件属性都可以绕过。就算把过滤词增加成上百个依然会有新招式绕过。真正可靠的做法是区分场景如果字段预期是“用户名、邮箱、ID”这种结构化数据用正则做白名单校验不符合就返回错误。如果字段预期是“富文本”入库前不要试图过滤渲染前必须净化。如果字段预期就是纯文本渲染前统一做HTML编码。模拟一个白名单校验的修复示例?php $name $_GET[name]; if (!preg_match(/^[a-zA-Z0-9_\-\u4e00-\u9fa5]{1,30}$/u, $name)) { http_response_code(400); exit(Invalid name); } echo preHello, . htmlspecialchars($name, ENT_QUOTES, UTF-8) . /pre; ?这里把字符范围、长度都限定死攻击者提交任何带符号、带标签的字符串都会被拒绝。对业务来说只要能确认用户名字本来就不该包含HTML白名单校验是最干净的。3.3 修复高级别CSP Trusted Types兜底DVWA高级别通常已经做了一些过滤但仍属于“看起来安全、实际可能被绕”的水平。就算你在服务端把可见的危险标签过滤了浏览器对畸形HTML的宽容解析依然可能留下可乘之机。所以到高级别修复我会直接上平台策略。在返回页面时设置响应头Content-Security-Policy: default-src self; script-src self; object-src none; base-uri self; require-trusted-types-for script; trusted-types dvwa-default;如果业务本身不依赖第三方脚本、也不用eval、不搞行内脚本这个策略不会带来太多改动成本。但它在“即使某个编码点漏了”时能兜底。我在本地实战时验证过CSP启用后原本能被高级别页面接受的反射型payload进入页面控制台直接报Refused to execute inline script because it violates the following Content Security Policy directive渲染流程中断用户看不到任何弹窗。需要注意的是CSP策略上线前一定要先上线收集报告的模式Content-Security-Policy-Report-Only: default-src self; script-src self; ...; report-uri /csp-violations;用报告模式跑几天看看业务哪些地方会误伤再切换成强制模式。一上来就强制很容易把正常业务打断。4. 存储型XSS留言板场景的完整防御4.1 数据入库前只校验合法性不强行过滤存储型XSS比反射型更危险因为payload会被存进数据库之后每个访问页面的人都会中招。DVWA的存储型XSS就是一个典型的留言板用户提交的名字和留言会渲染给所有后续访问者。很多人第一反应是“在入库前把危险字符串过滤掉”。这个想法听起来正确但实现起来问题很大。因为你不知道这条数据将来会被用在哪些上下文是显示在HTML里还是放进href属性还是拼进JSON供前端读取同样的数据在不同上下文需要不同处理。入库前更应该做的是业务合法性校验比如留言长度、是否为空、字段类型是否正确。对于“是否允许HTML标签”这个问题我的建议是数据库里存原文把“净化”放在输出边界。模拟留言板入库伪代码?php if (mb_strlen($message) 500) { exit(Message too long); } $stmt $pdo-prepare(INSERT INTO comments (name, message) VALUES (?, ?)); $stmt-execute([$name, $message]); ?不要在这里用str_replace去删标签也不要试图把换成lt;之后再存。入库前做修改不仅可能破坏用户原始内容还会让你在调试时看到的数据和用户提交的数据不一致。4.2 渲染时用DOMPurify Handlebars/Vue配合存储型XSS的修复重点在渲染侧。如果是纯文本留言直接HTML编码即可。但很多留言板允许用户输入换行、链接、加粗之类的简单格式这就需要富文本净化。我常用的组合是DOMPurify加模板引擎或框架默认转义。比如用Handlebars渲染时const template Handlebars.compile( div classcomment div classname{{name}}/div div classmessage{{message}}/div /div ); const html template({ name: comment.name, message: DOMPurify.sanitize(comment.message, { ALLOWED_TAGS: [p, br, a, strong, em], ALLOWED_ATTR: [href] }) });如果用的是Vue这个场景应该写成div classcomment p{{ name }}/p div v-htmlsafeMessage/div /divdata() { return { name: comment.name, message: comment.message }; }, computed: { safeMessage() { return DOMPurify.sanitize(this.message, { ALLOWED_TAGS: [p, br, a, strong, em], ALLOWED_ATTR: [href] }); } }这里有一个很容易忽略的点即使DOMPurify过滤后链接的href仍然有可能被浏览器以很隐晦的方式解析成脚本协议。所以净化配置里一定要带协议白名单。在DOMPurify较新版本中javascript:默认会被拦掉但不同版本的默认行为不完全一样。为了不出意外我会在配置里显式加上URI正则。4.3 数据输出到其他上下文时不能只用同一套编码存储型数据经常不只在一个地方展示。同一个用户名可能出现在HTML列表里也可能出现在input的value属性里还可能出现在前端JS读取的JSON里。这些上下文的转义规则完全不同HTML实体上下文转义 属性上下文在HTML实体基础上还必须处理引号JS字符串上下文需要转义\ 和换行URL上下文需要校验协议所以唯一的统一规则是“根据上下文选择不同处理”而不是找到一个万能的编码函数到处调用。为了减少这种复杂性我习惯把一个数据的所有输出点列出来逐个确认它属于哪个上下文然后在每个输出边界分别处理。写代码时再顺手加注释说明“这里不可信数据HTML上下文转义”对后来维护的人非常有帮助。5. 在实验环境里落地CSP与Trusted Types5.1 开启方式与HTTP响应头如果你用的容器支持修改响应头可以直接在服务端配置里加上CSP。以常见的Nginx反代为例add_header Content-Security-Policy default-src self; script-src self; object-src none; base-uri self; require-trusted-types-for script; trusted-types dvwa-default; always;如果服务端不方便改也可以在页面头部用meta标签临时测试meta http-equivContent-Security-Policy contentdefault-src self; script-src self; require-trusted-types-for script; trusted-types dvwa-default;注意require-trusted-types-for在meta里能不能生效取决于浏览器对CSP通过meta限制的部分能力。Chrome支持大部分策略通过meta传递但为了稳妥生产环境建议用HTTP响应头。我最初就是图省事只在HTML里写了meta结果某些策略项不生效排查了半天才发现是传输方式的问题。5.2 自己实现一个Trusted Types策略Trusted Types的核心是trustedTypes.createPolicy。它会返回一个带createHTML、createScript、createScriptURL等方法的策略对象只有用这些方法生成的值才能被安全地赋给innerHTML等危险sink。一段实用的策略代码if (window.trustedTypes trustedTypes.createPolicy) { const purifyPolicy trustedTypes.createPolicy(dvwa-default, { createHTML: (input) { return DOMPurify.sanitize(input, { USE_PROFILES: { html: true }, RETURN_TRUSTED_TYPE: true }); } }); // 之后在业务处 const trustedHTML purifyPolicy.createHTML(userInput); document.getElementById(preview).innerHTML trustedHTML; }看到RETURN_TRUSTED_TYPE: true了吗这是关键。DOMPurify在最新版本里支持直接返回TrustedHTML类型对象而不只是字符串。这样从净化到赋值全程都没有“原始字符串被直接塞进DOM”的窗口。如果浏览器不支持Trusted Types这段代码不会执行业务会退回到普通的DOMPurify字符串净化逻辑。这个兼容性降级是可控的不支持的老浏览器本来就不具备强制Trusted Types能力但我们仍然有DOMPurify在兜底。5.3 兼容性处理与降级方案Trusted Types目前支持度最好的是Chromium系浏览器Firefox和Safari还处于逐步跟进状态。所以线上项目不能把Trusted Types当作唯一依赖而要把它当成“增强型防御”。我建议的分级方案是第一层框架默认转义或手工输出编码这是所有浏览器都需要的。第二层DOMPurify净化富文本这是当前代码必须保证的。第三层CSP限制脚本来源。第四层Trusted Types限制DOM注入入口只对支持它的浏览器生效。四层之间互相独立即使某一层失效后面的层仍然发挥保护作用。这也是我为什么每次在DVWA里做完单点修复还要顺手验证CSP和Trusted Types的原因。单独看某一层的修复都可能有漏洞只有串成链路才让人放心。6. 实战中踩过的坑与排查速查表6.1 五个常见坑第一个坑前端净化了后端没转义。前端用DOMPurify把数据洗干净后展示看起来没问题但同一个接口如果被其他客户端调用或者后端在服务端渲染场景里直接拼HTML就漏了。净化应该放在尽可能靠近输出的位置但后端输出边界同样需要编码。第二个坑只处理了innerHTML没处理属性。很多XSS不在innerHTML发生而是在href、src、style属性里。比如把用户输入直接放在href里攻击者可以用javascript:伪协议绕过HTML实体编码。属性上下文必须同时做协议白名单校验。第三个坑把JSON字符串直接塞进script标签。这种问题在数据埋点、页面初始状态传输时很常见。比如script var userInfo ?php echo json_encode($userInfo); ?; /script虽然json_encode能转义引号但老版本PHP或特殊配置下/script这类字符依然可能闭合script标签。更稳妥的方式是用专门的JSON上下文转义或者把数据放到textContent里再读取。第四个坑黑名单被语义绕过。过滤script、过滤onerror、过滤javascript:但攻击者换一个编码方式、换一个绕过方向黑名单就失效。黑名单只能作为辅助核心还是输出编码和输入白名单。第五个坑CSP策略设置得太宽松。比如script-src unsafe-inline这等于允许内联脚本执行攻击者只要能注进一个script标签就直接绕过CSP。既然要做CSP就要狠一点内联脚本和eval尽量都禁掉通过外链文件和非ceipt的非行内方式承载业务逻辑。6.2 排查表格现象可能原因排查方向弹窗仍然出现数据在不同输出点未统一编码检查所有渲染入口确认是否每个地方都做了转义或净化过滤后显示乱码入库前做了HTML实体转换导致原文被破坏改为入库存原文渲染前做编码DOMPurify过一遍仍有风险使用了过旧版本升级到最新版并确认配置项没有允许危险标签页面功能被CSP拦掉业务存在内联脚本或eval把内联脚本迁移到外链文件必要时使用unsafe-inline的过渡方案但尽快消除Trusted Types不生效浏览器不支持或CSP头缺失用Chromium系浏览器测试确认响应头包含require-trusted-types-for富文本里的链接打不开协议白名单限制过严确认ALLOWED_URI_REGEXP覆盖业务需要的协议这个表格是我在实际审查项目时经常对照的。排查XSS修复效果时不要只看“弹窗没弹出来”还要看控制台有没有报错、网络请求有没有异常、数据有没有到达预期渲染点。6.3 DVWA环境里怎么快速验证修复效果在DVWA里验证防御效果我一般分成三步。第一步把常见测试载荷输入到对应输入框先确认原漏洞能触发。如果连原始漏洞都触发不了说明实验环境本身有问题后面验证没有意义。第二步应用防御代码后再输入同样的载荷这时应该看到页面把载荷当作文本显示出来浏览器控制台不报任何脚本执行错误。第三步用开发者工具把带载荷的请求和响应完整看一遍确认服务端返回的HTML里危险字符已经被转义或净化。这一步很关键因为很多时候前端页面看起来正常但网络面板里的响应原文依然带着可解析的危险结构。我在本地DVWA环境里验证时习惯同时打开两个浏览器一个保持旧版本代码一个用新版本代码。两边输入完全相同的数据对比响应原文和渲染结果能非常直观地看到哪里做了修复、哪里还有遗漏。这种笨办法在快速迭代时反而最省时间。7. 写在最后的几句实在话如果你真想提高XSS防御水平不要只盯着payload花样要多想输出点、上下文、浏览器解析行为。我在实际负责前端安全治理之后最大的体会是安全不是某个库的功劳而是一套系统的习惯。用DOMPurify就够了吗不够。用了Vue或React就等于安全了吗也不够。框架帮你挡住了最容易出错的那一层但属性、URL、script标签上下文、CSP配置仍然需要你逐个确认。在DVWA里把这一套完整走一遍比看一百篇理论文章都管用。你会在实践中发现某个库的默认配置并不能覆盖所有场景某个看似安全的写法在特定浏览器里依然可能被利用。只有把编码、净化、CSP和Trusted Types都理解到位并且形成自己的checklist线上代码才会真正让人睡得着觉。最后分享一个小建议每在一个项目里修复完XSS都要把当时绕过你防御的那个payload记下来放进回归测试用例。下次改版升级重构时重新跑一遍这些用例你就知道防御是不是还活着。这个习惯我坚持了很久拦截过好几次差点被带上线的回归漏洞。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑