☰
从img onerror拆解XSS:解析、绕过与防御全流程
2026/10/21 16:51:44 网站建设 项目流程

新手第一次在地址栏或者表单里见到<img src="x" onerror="alert('XSS')">这一串,多半会愣一下:这也能算XSS?它短得有点不像话,却能在别人浏览器里弹出一个对话框。我在做代码评审、给开发者讲安全基础时,经常从这一行开始,因为它把“用户输入为什么会变成代码”这件事浓缩到了极致。

很多同学认识XSS(跨站脚本攻击)时,第一个记住的载荷就是它。可如果只是机械地记住“输入img加onerror能弹窗”,那遇到一个过滤了img标签的系统,你照样会卡住。真正有价值的是理解它到底由哪几部分组成、浏览器从解析到执行走了哪些步骤、过滤系统的漏网之鱼又漏在哪里。把这串字符拆透,反射型XSS、存储型XSS的入门逻辑基本也就通了。

下面我会以一个做过多次安全评审的从业者视角,把这串字符从标签结构、事件原理、解析流程、绕过逻辑、防御方法到排错经验全都过一遍。适合刚入门的安全测试同学,也适合被安全测试反复提工单的前端开发者。

1. 整体理解:这行代码是一个“微型事件程序”

1.1 一句话概括它的作用

这个载荷的实质是:构造一个必然加载失败的图片,再在失败回调里塞入一段JavaScript。正常的img标签负责显示图片,浏览器看到它会发起图片请求,成功就渲染,失败就什么都不显示。而这个载荷把“失败”这个时机变成了代码执行的时机。你可以把它理解成:挂了一幅画,但挂绳一碰就断,同时墙上贴了张纸条写着“如果画掉下来,就放一首歌”。放在Web语境里,那首歌就是alert('XSS')。

它不依赖用户点击,也不依赖额外输入,只要浏览器成功把这个标签解析成DOM节点,图片加载失败的事件一发生,载荷就会自动执行。这是它作为XSS验证样本最大的优点:触发条件极低,几乎只要页面里出现这个字符串,就能确认脚本注入点存在。

1.2 为什么选img而不是script或其他标签

很多人第一反应是:既然要执行JS,直接写一个<script>alert('XSS')</script>不就行了吗?问题在于script标签太招摇了。安全过滤器、WAF、开发者的输入校验,几乎都会优先检查“script”这个词。而img是一个完全合法、用途普通的HTML标签,大量站点会在富文本、评论区、头像等位置允许用户提供图片地址,于是这个看似人畜无害的标签就成了最容易被放进HTML里又不容易被怀疑的载体。

另一个原因是:img是空元素(void element),不需要成对出现,也不用写闭合标签。载荷可以压缩成一行,没有多余的尾巴,甚至在长度受限的场景下被截断后依然可能生效。再加上img天然支持onerror这类事件属性,等于一个合法标签自带一个“自动执行通道”。相比之下,如果要在普通受控标签上找执行机会,往往得绕更多弯。

1.3 标签的总骨架:标签名、属性、事件属性

从HTML语法角度看,这行代码由三个部分拼起来:标签名img、普通属性src、事件属性onerror。标签名决定它是哪种元素,普通属性决定它的资源来源,事件属性决定它在特定时刻要执行的脚本。浏览器在解析时并不会区分“这是用户输入的”还是“这是开发者写的”,只要它出现在HTML解析上下文里,就会被当成真正的标签处理。这个“不区分来源”的特性,正是XSS得以存在的根基。

所以,不要把这行代码看成一个普通字符串,而要看成一个被浏览器当作HTML解析的最小程序。src决定“什么时候失败”,onerror决定“失败后做什么”,alert('XSS')决定“做什么的具体表现”。它们三个协作,完成了一次从代码注入到代码执行的通路。

2. 逐个部件拆解:src、onerror、alert分别干了什么

2.1 src="x":制造一个必然失败的请求

src属性在img标签里表示图片资源的地址。正常情况下应该写一个可访问的URL或者相对路径,例如src="/images/logo.png"。但这里写的是x。这个值在大多数站点的当前路径下会被解析成一个相对URL,指向一个几乎不存在的图片资源。结果是浏览器发起请求后拿到404页面或者无法识别的响应体,图片解码失败,加载流程走到错误分支。

为什么非要用一个注定失败的地址?因为onerror要等元素触发error事件才会执行。如果src指向一张正常图片,图片加载成功,触发的是load事件而不是error事件,alert就不会运行。所以这个x的作用就是制造一个“必失败条件”。有些变体会写src=1、src=a.png,原理都一样,反正是随便填一个大概率无效的资源。还有一个不容易注意到的点:无效地址的请求往往很快失败,不会让页面加载变慢,这让载荷响应更迅速、更不容易被察觉。

2.2 οnerrοr="...":把错误时机变成执行机会

onerror是img元素的一个事件处理属性,可以理解为“当发生错误时”。在DOM事件机制里,当元素资源加载失败,浏览器会向该元素派发一个error事件。这个事件不冒泡,但它仍然会被目标元素上的处理器捕获,所以直接写在img标签上的onerror属性,必然能在该元素自身出错时收到通知。

onerror的属性值是一段JavaScript代码。事件触发时,浏览器会把这段字符串编译成一个事件处理函数,然后执行。也就是说,属性值不是纯文本,而是可执行脚本。这正是攻击者需要的入口。有一点容易忽略:这段代码的执行上下文并不空。函数内部this指向当前img元素,还能按作用域链访问到所在的文档、窗口等对象。所以有人会在这里写this.src、document.cookie之类的代码,但那就属于更深入的利用方式了,这篇文章不展开。

2.3 alert('XSS'):用弹窗确认代码执行

alert是浏览器提供的全局函数,调用后会在页面弹出一个模态对话框。选择它作为载荷内容,是因为安全测试需要最直接、最醒目的确认方式。如果换成console.log('XSS'),代码可能执行了,但你不一定知道,除非恰好开着控制台。而alert一弹出来,所有人都能看见,也容易被自动化测试工具捕获。另一个好处是alert会阻塞页面后续交互,方便测试者观察注入点位置和触发时机。

括号里的'XSS'是一个字符串参数,用来控制弹窗文字。你也可以把它换成alert(1)、alert(document.domain),本质都一样,只是确认的信息不同。这个写法里藏着一个细节:事件属性本身用双引号包裹,所以内部的JS字符串可以用单引号,而不会与HTML属性解析冲突。外层双引号和内层单引号相当于互为“隔离墙”,这让载荷写法非常干净。

2.4 引号、括号、尖括号:边界字符的解析规则

这一小节专门讲为什么字符必须这样配对。在HTML里,标签以<开头,然后是标签名img,之后是空格分隔的属性列表。每个属性由属性名、等号、属性值组成。属性值可以用双引号、单引号,也可以不加引号。这里src="x"和onerror="alert('XSS')"都使用双引号,内部JS字符串用单引号,所以属性值不会提前闭合,整段代码语义完整。

如果去掉外层引号,写成onerror=alert('XSS'),其实也能运行。无引号属性值不能包含空格,而alert('XSS')内部没有空格,所以这个写法在语法上成立,只是可读性差,而且一旦需要在alert里加入空格就会出错。结尾处的>表示标签结束,HTML解析器知道属性列表到此为止,标签正式闭合。因为img是空元素,所以不需要再写</img>。

这里还要澄清一个常见困惑:当你把这段内容粘贴到页面里,浏览器不会把它当作普通文本显示,而是<成为标签开始信号,>成为标签结束信号,整段内容被当作HTML标签处理。所以页面文字不会出现“img src=...”,而是直接产生一个隐藏的DOM节点。

组成部分写法作用
标签名img声明这是一个图片元素,借助资源加载机制
普通属性src="x"指定资源为无效地址,制造加载失败条件
事件属性οnerrοr="alert('XSS')"绑定失败回调,触发后执行JavaScript
闭合符号>结束属性列表,使标签完整闭合

3. 浏览器一步一步解析到执行,链路怎么走通

3.1 HTML解析器是怎么认出一个新标签的

当HTML文档加载时,浏览器会进入解析阶段。解析器遇到<字符,状态机从数据状态切换到标签开始状态,然后读取标签名img,进入标签名状态。接下来遇到空格,开始解析属性名,从src到onerror逐个读取,直到遇到>,标签宣告结束,一个对应的DOM节点被创建出来。整个过程里,解析器不会区分一个标签是开发者写的还是用户注入的,只要符合语法,它就会被当成真正的标签。

这个机制解释了为什么输出编码能防XSS:如果把<转成&lt;,解析器在数据状态遇到&lt;时不会把它当作标签开始,整个载荷就退化成一段普通文本。HTML解析器对属性名的大小写不敏感,ONERROR和onerror等价;对属性值的处理也比较宽松,它会先做HTML实体解码,然后把结果交给JavaScript引擎。这种多层解码正是很多黑名单过滤失效的根源。

3.2 图片加载失败后,error事件如何产生

DOM节点构建完成后,浏览器会为img元素发起资源加载请求,请求地址是src属性值解析后的绝对URL。如果服务器返回404、响应体不是有效图片,或者网络连接失败,图片解码器就会报错。浏览器随后在当前img对象上触发一个名为error的事件。这个事件的触发点是元素本身,而且按照规范不冒泡,所以必须有一个监听器直接挂在目标元素上才能捕获,而onerror属性恰好正好绑定在这个img元素上。

事件派发之后,浏览器会检查该元素是否配置了对应的事件处理函数。如果存在onerror属性,它会把属性值当作函数体来编译并执行。从事件队列的角度看,这属于事件循环中的某个任务,不会阻塞页面的基础解析。但alert一旦被调用,就会立刻弹窗并阻塞用户交互,直到用户关闭弹窗。因此,看到弹窗出现,就说明整条事件链路已经完整走通。

3.3 内联事件处理器如何编译执行

将onerror属性值转成函数,是浏览器内部设置事件处理器的标准流程。浏览器会生成一个函数,并让它处于一条特殊的作用域链中,链上通常包含当前元素、所属表单(如果有)、document和window。所以你在属性值里可以直接写this、document、window这些对象。随后浏览器用JavaScript引擎解析这段代码,如果语法正确,就会在事件触发时立即执行。

这里有一个非常常见的误解:有人以为onerror里的内容和<script>标签一样,所以只要CSP没开,就一定能执行。其实CSP确实可以拦内联事件处理器,但前提是script-src指令中不包含'unsafe-inline'。如果站点为了兼容第三方脚本,随手加了unsafe-inline,那么这段onerror照样能运行。这也是为什么防御一定要看实际配置,而不是想当然地认为“现代浏览器默认能挡住所有XSS”。

4. 为什么经典payload总能绕过黑名单过滤

4.1 黑名单拦得住img,拦不住整个标签家族

很多系统对用户输入做黑名单过滤,看见<script>就删,看见img就拒。问题是HTML里能绑定事件属性、能触发外部加载的标签远不止img一个。<svg onload>、<body onload>、<video onerror>、<marquee onstart>都能达到类似效果。只要你是黑名单思维,就得把整个标签列表和整个事件属性列表全部枚举一遍,这几乎不可能持续维护。攻击者只需要在常见标签表里逐个试,找到一个漏网之鱼就算成功。

从成本上看,这个对抗完全不对称。防御方需要跟踪浏览器的新特性和新事件,维护一份可能永远不完整的禁用清单;攻击方却只需要不断尝试。成熟的安全实践很少把重点放在“过滤具体标签”上,而是放在“输出编码”和“上下文隔离”上。黑名单只适合做短期的临时缓解,不适合做长期防线。

4.2 事件属性这个设计,让属性级过滤成本飙升

如果过滤器选择保留img但删除onerror,依然不保险。HTML事件属性数量非常多,onload、onclick、onmouseover、onfocus、onchange,甚至onanimationend这类冷门事件都能被当作入口。删掉一个onerror,换一个事件就能继续执行。于是安全团队又开始维护“危险事件属性”列表,一边要覆盖新事件,一边还要防大小写和编码变形,维护成本迅速膨胀。

更关键的是,事件属性值并不是一个简单字符串。你可以在属性值里使用HTML实体编码,比如写成onerror="alert&#x28;1&#x29;"。浏览器解析属性值时,会先把&#x28;解码成(,交给JavaScript引擎时已经还原成正常代码。如果过滤器只检查原始字符串里的括号或关键字,这种编码可以直接绕过。这种“先解码后执行”的机制,让字符串匹配变得非常不可靠。

4.3 编码、大小写、JS混淆:同一个载荷的无数张脸

实际测试中,你会看到大量变体:<IMG SRC=x ONERROR=alert('XSS')>靠大小写绕过,<img src=x onerror=eval(atob('YWxlcnQoJ1hTUycp'))>靠编码隐藏代码,<img src=x onerror=top['al'%2b'ert'](1)>靠拼接拆开关键字。这里不需要背各种混淆技巧,只需要理解一个内核:浏览器在最终执行之前会经历HTML实体解码、URL解码、JavaScript解析等多层处理,任何一层解码都可能成为黑名单绕过的跳板。

所以我会经常提醒身边的开发者:与其追问“为什么这段又绕过了”,不如接受一个现实——只要输出端没有把用户数据隔离为“纯文本”,攻击者就永远有机会把数据重新解释成代码。明白这一点,防御思路才会从“怎么删得更干净”切换成“怎么让代码上下文和数据上下文彻底分离”。

5. 真正要防它,不能只靠“别用img”

5.1 核心不是过滤恶意字符串,而是输出时编码

XSS的本质是“数据与代码混淆”:用户提供的数据被当成了HTML代码输出。所以防御的核心是让输出端明确告诉浏览器“这是数据,不是代码”。在HTML元素内容上下文里,把<转成&lt;、把>转成&gt;,浏览器就只会把它当作文本显示;在属性上下文里,需要把引号转成&#34;,避免用户输入闭合属性后注入新的事件处理器。这一步叫“上下文感知编码”,它比任何黑名单都可靠。

很多模板引擎和现代前端框架默认会对插值做转义,这是好事。但如果你手写字符串拼接、用innerHTML塞数据、或者把服务端渲染的富文本原样输出,编码防线很容易被绕过。我自己常用的执行标准是:凡是用户可输入、且要回显到页面的位置,统一走带上下文感知编码的API;宁可多转义一个字符,也不要少转义。

5.2 用CSP掐断内联脚本的执行路径

内容安全策略(CSP)是针对<img onerror>这类载荷很有效的一层防线。如果站点配置了script-src 'self',浏览器会禁止所有内联脚本,也包括onerror事件处理器。图片照样加载失败,但alert不会执行。配合object-src 'none'、base-uri 'none'等指令,可以进一步压缩攻击面。这是纵深防御里非常值得投入的一项配置。

不过CSP也有现实约束。如果开发者为了接入统计脚本随手加了'unsafe-inline',前面所有限制等于白做。某些第三方SDK和业务页面也依赖内联脚本,所以CSP很难一次到位。我的经验是从report-only模式开始跑一段时间,观察线上误杀情况,再逐步收紧。重点是让组织形成一条明确的策略,而不是因为麻烦就跳过。最怕的是配置了一版很严格的CSP,业务部门叫苦两声又把它删了,那就等于没做。

5.3 从开发流程上让XSS用例跑不起来

技术手段之外,流程上也该有东西。我建议前端工程里维护一份最小的XSS校验用例集,至少包含这个img onerror载荷、<svg/onload=alert(1)>、带引号闭合的变体,以及几个编码变形。每次上线前跑一遍,把这些用例固化到自动化测试里。很多团队反复被安全测试提XSS工单,就是因为这类输入从没进入过回归范围。

还要提醒一个容易漏的位置:不只是服务端渲染需要编码,前端用innerHTML拼DOM同样可能触发XSS。浏览器端的操作尽可能用textContent而不是innerHTML;即使要渲染富文本,也必须经过白名单清洗。这个原则比我前面讲的任何单个修复都重要,因为漏洞往往出现在“我没想到这里也能插入字符串”的角落。

6. 常见问题与排错实录

6.1 复制了payload却不弹窗,怎么排查

这个问题几乎每周都能遇到。最常见原因是CSP:如果站点配置了不允许内联脚本的策略,payload会被浏览器静默阻止。第二个常见原因是上下文不对,载荷被插入到了JavaScript字符串里而不是HTML标签上下文。例如var msg = "<img src=x onerror=alert(1)>";,此时尖括号和引号只是JS字符串内容,根本不会被解析成DOM节点。第三种常见问题是服务端对<和>做了实体编码,页面源码里显示的是&lt;img ... &gt;,浏览器同样只会把它当文本。

遇到不弹窗,排查顺序可以固定下来:先在开发者工具里查看最终渲染出来的DOM树,确认payload有没有作为元素节点存在;再看Console面板有没有CSP告警;最后检查事件触发时机,比如图片是否在脚本监听前就已经加载完毕。这三步走完,大多数“为什么不弹”都能定位出来。

6.2 这类payload只能在授权环境下测试

把这条放在排错里,是因为它太重要了。不管这段载荷看起来多像“一句玩笑代码”,它都属于攻击代码的验证样本。没有对某个站点的明确授权之前,绝对不要把它贴到别人的评论框、搜索框或者URL里。合法的方式是:在你自己的测试环境、本地搭建的提测站点、或者有书面授权的项目里使用。我给开发同学做演示时,全部在本地起一个极简HTML文件,现场构造载荷,确保整个过程完全可控。

如果偶然发现某个线上系统存在疑似注入点,正确做法是截图记录、报给安全负责人走修复流程,而不是为了“验证一下”在生产环境反复弹窗。记住:安全测试的目的是帮系统变得可靠,不是证明自己有能力弹窗。授权边界这件事,踩一次线的风险远大于任何技术收获。

6.3 还有哪些细节会影响执行结果

一个是图片缓存。如果src指向的路径在浏览器或CDN里恰好有缓存,图片可能加载成功,onerror自然不触发。所以载荷里的src要写一个几乎不可能命中缓存的随机值,x这个用法就很省心。另一个是浏览器内置的XSS防护机制。部分浏览器对反射型XSS有基础过滤,当URL参数里的载荷原样反射回页面时,浏览器可能直接阻断整段HTML的解析。这种保护并不完全可靠,但确实会干扰测试,需要结合具体环境来判断。

还有一个很常见的细节:onerror属性值里的引号配对错误会导致整个载荷失效。比如写成alert("XSS"),内层也用双引号,那么HTML属性会在第一个双引号处提前闭合,属性值只剩alert(,后面内容跑到标签外变成普通文本,弹窗自然不出现。所以想替换自己的内容时,一定记得把外层属性引号和内层JS字符串引号区分开。

我个人做了这些年安全相关的工作,最大的体会是:背下来一堆payload不算本事,能把一个经典样本的前因后果讲清楚才是真功夫。这个<img src="x" onerror="alert('XSS')">载荷虽然简单,但浏览器解析、DOM事件、CSP、编码处理、黑名单局限这些东西全部浓缩在里面。下次你在代码评审里再看到它,或者自己写防御测试用例时,能比别人多想一层,这篇文章就没白看。最后再说一个小建议:拿手边一个测试页面,把src="x"改成正常图片地址,亲眼看看alert不再弹出的那一下,你会对“错误处理”这个机制留下更直观的印象。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询