ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

XSS漏洞实战复盘:从信任边界到代码审计与防御

XSS漏洞实战复盘:从信任边界到代码审计与防御

如果让我从这些年接触过的Web漏洞里挑一个每次复盘都有新发现的,大概就是XSS。最近有朋友问我,扫描器不是已经把XSS扫得差不多了吗,人工还费什么劲。这个问题我在自己的笔记里写过很多次,这次算第106篇,单独聊聊几个我挖到过、回味起来仍然觉得很有意思的XSS漏洞。这几个洞都不算那种“一眼就能弹窗”的低垂果实,有些藏在很深的渲染链路里,有些甚至连前端代码里都看不出问题,但最后都指向同一个本质——浏览器信任了应用不该信任的输入。希望这篇能帮你重新审视自己项目里的每一个输出点,也顺便聊聊我踩过的一些坑。

1. 为什么是XSS:难的不是弹窗,而是信任边界

XSS全称跨站脚本攻击,归根结底就一句话:用户输入的数据被当成了页面代码来执行。但这句话说起来简单,落到真实系统里就变得很复杂。因为现代Web应用早就不只是“服务端渲染一段HTML”那么简单,前端框架、图表库、富文本编辑器、第三方SDK一层叠一层,每一层都可能在某个环节把自己的数据塞进HTML、塞进JS、塞进URL。只要有一层忽略了边界,漏洞就出现了。

1.1 扫描器扫得到和扫不到的事

很多团队的安全测试依赖自动化扫描器,这当然有用。扫描器对“参数反射到HTML且没有过滤”这类简单场景非常高效,但对两类现象基本无能为力。第一类是跨层的数据流,比如后端把内容编码存库,前端框架在渲染时又解码,整个过程黑盒测试很难看到。第二类是业务逻辑里的特殊输入点,比如某个配置项、某个模板变量、某个编辑器扩展,扫描器不知道这些是“用户可控”的,自然也不会往里面插入测试字符串。

我挖到过的几个有意思的XSS,都不是扫描器报的。它们要么是我在改造前端工程时顺手发现的,要么是同事跟我讨论某个报错时连蒙带猜定位的。真正让我兴奋的不是“弹窗成功”那一刻,而是顺着数据流一步步追到根因、发现自己落下了哪个上下文的那种通透感。

1.2 我判断一个XSS“有趣”的标准

如果一个XSS只是<script>alert(1)</script>直接反射回来,那它只说明开发完全没做编码,问题很严重但也很单调。我觉得有意思的案例,通常满足下面至少一条:

  • 输入点本身看似安全,甚至在过滤规则覆盖之下,但换了一条路径就绕过去了。
  • 漏洞不在开发者写过的那段代码里,而在某个组件、某个库、某个模板工具的隐式行为里。
  • 修复方案不只是“过滤几个字符”,而是要把整个输出编码策略、渲染策略理一遍。

这几个特征,基本就是我后面这四五个案例的共同底色。

2. 案例一:一个温驯参数背后的DOM型XSS

先聊一个我印象很深的DOM型XSS。场景是某后台系统的报表导出页,页面上有一个标题栏,会根据URL参数动态变化。URL长这样:/reports/export?page=月度销售&type=pdf。初次看这个页面,我第一反应是比较安全的:参数在URL里,服务端也做了长度和字符集校验,数据库里根本没存这个参数,页面也没有明显的innerHTML。但问题往往就藏在“没有明显”里。

2.1 线索浮现:控制台报错比弹窗先一步

当时我正在调试页面样式,随手改了一下URL参数,在page后面加了一串__proto__测试字符串,然后刷新。页面没有弹出任何东西,但控制台里多了一条报错:Unexpected token ... at ...,堆栈指向一个图表组件。看着这条报错我愣了一下,因为正常来说,一个字符串被塞进标题区域,顶多显示成文本,怎么会让JS解析出错?这说明参数最终进入的位置不是HTML文本,而是被某个内部逻辑拼接进了模板、甚至进入了脚本执行路径。

顺着堆栈点进压缩后的JS,我很快定位到:这个图表库在渲染图表配置项时,会把title.text的值通过一段模板字符串拼进SVG标签,然后用innerHTML插入到容器节点。也就是说,URL里的page参数经过URLSearchParams解析后,直接变成了图表库的title.text,图表库内部再拼进DOM。而我们前端团队自己写的代码里确实没有innerHTML,问题出在第三方组件内部。

2.2 排查动作:沿着前端数据流追到图表库

这类问题要想快速定位,我建议按下面几步来,尤其在别人告诉你“我们没用innerHTML”的时候更要坚持执行:

  • 第一步,修改URL参数里加入明显且无恶意倾向的标记字符串,比如zzz、__proto__、<!--,观察页面渲染或控制台变化。标记的意义是让它出现在浏览器解析DOM的某个位置,你才好知道自己输入到底去了哪。
  • 第二步,打开开发者工具,在Sources里搜索location、URLSearchParams、location.hash这些关键词,能帮你快速找全前端所有读取URL的入口。
  • 第三步,在全局搜索innerHTML、outerHTML、insertAdjacentHTML、document.write这几个危险渲染点,不要只看业务代码,node_modules里也要搜。
  • 第四步,如果页面用了框架,直接打断点看渲染时最终写入DOM的值是什么,配合执行栈往回追数据来源。

按这个顺序,我当时前后花了十几分钟就确认了完整链路:URL参数 → URLSearchParams → 图表配置项 → 组件内部模板 → innerHTML。验证用的内容也是简化过的标准样本,主要证明输入可以进入HTML解析流程,确认影响范围后立刻还原参数并提交了报告。

2.3 修复与同类问题的自查方法

这次修复有两层。首先把图表库升级到支持安全渲染的版本,这是治标的做法;其次,给输入到图表库的配置项做一次前置规整,把所有来自URL的核心字段统一经过textContent语义的转义再交给组件。简单说,不让任何“用户可控字符串”以原始形态进入组件配置里。

我在这个案例里学到的最重要一条:组件封闭性不等于渲染安全性。很多人觉得用了前端框架就能自动转义,但这个保证只对“框架自己管理的模板”成立。一旦你调用第三方库、直接操作DOM、或者把字符串塞进一个会走innerHTML的配置项,框架的转义就形同虚设。后来我在团队定了条规矩:前端工程里凡是给第三方组件传字符串配置项,默认都当“不可信输入”,先转义再传。

3. 案例二:属性值里的引号争夺战

第二个案例来自一个内容社区的消息通知模块。用户昵称会被拼接进一条通知文案里,展示形式大概是“A同学 回复了你”。第一眼看去,服务端做了过滤:昵称里的<和>都会被转义成实体。尖括号都被堵死了,还能有什么问题呢?问题出在昵称不仅出现在文案里,还被塞进了一个HTML属性的值里。

3.1 看似正常:转了尖括号就万事大吉

实际页面代码类似这样:

<span title="新消息来自 {nickname}">A同学 回复了你</span>

开发者把 nickname 当作纯文本,但是放在了title属性的值里。服务端只做了<和>的实体转义,却没想到单引号或者双引号在属性值里同样是有语法含义的。如果昵称内容里包含一个"onmouseover="...,那它就不再是“值”,而是变成了一个全新的属性,XSS就从这里展开。

这事的诱因很典型:很多开发对“转义防XSS”的理解,还停留在“挡住尖括号”的层面上。但浏览器解析HTML的规则是分上下文的,标签文本里尖括号危险,属性值里引号危险,内联JS字符串里引号和换行危险,CSS里又是另一套规则。一个固定的转义函数不可能覆盖所有上下文。

3.2 判定与验证:单引号属性逃逸的完整链路

当时验证用的输入结构做了一层简化,大致思路是让昵称以"闭合属性,然后插入一个事件属性。我并没有直接使用完整可利用的URL或真实业务数据去污染线上内容,而是在本地测试环境构造了一段最小化样本,确认能被浏览器解析为事件属性之后,就停下验证、清理数据。

这个案例“有意思”的点在于:它没办法靠单纯搜<script>扫出来。按我现在的经验,所有以引号包裹的属性值,都应该视作“属性上下文”,如果内容包括用户输入,必须做属性值编码。也就是把"编码成&#34;、把'编码成&#39;、把&编码成&amp;。很多框架默认的文本转义函数已经做了这些事,但如果你手工拼HTML字符串,就很容易漏。

3.3 输出编码必须分上下文:一张速查表

后来我整理了一张表,内部的每条规则都是踩过坑换来的:

数据将要进入的位置安全编码方式说明
HTML标签之间的文本HTML实体编码至少编码<、>、&
HTML属性值(带引号)属性编码必须对引号做编码,不能只挡尖括号
URL属性(如href、src)URL白名单校验只允许http、https等协议开始
内联JS字符串JS字符串转义配合JSON序列化,杜绝引号逃逸
内联CSS禁止用户输入直接拼接尽量走CSS变量或白名单

这张表现在已经成为我审查前端输出编码的标准清单。只要发现一处用户数据要进入上面任意上下文,我就会去对应的编码规则里找缺口。你也完全可以拿它当自查表用,逐行过一遍自己的渲染链路,大概率能找到一两个平时没注意的隐患。

4. 案例三:富文本编辑器里那条“看不见”的payload

如果说前两个案例还属于“输入点藏得深”,第三个案例就更隐蔽了,属于“看起来逻辑很安全,实际编码和解码的顺序不对,导致过滤被二次解码绕过”。它发生在某个UGC平台的个人简介编辑器上,用户可以用富文本编辑器编辑个人简介,提交后有服务端做白名单过滤,只允许b、strong、i、a等少量标签,a标签的href属性还只能以http或https开头。这套配置乍看挺稳。

4.1 白名单过滤为何形同虚设

白名单过滤本身没有大问题,问题出在渲染路径上的“双重解码”。大致链路是这样的:编辑器把内容保存到服务端时,会把用户输入按照HTML规则序列化,其中包含的<会被存成&lt;这类实体。服务端收到后,先做一次实体解码,再做白名单过滤,最后把过滤后的内容存进数据库。到这里,逻辑还没有问题,因为过滤发生在解码之后,能滤掉真实标签。

但奇葩的是,前端编辑器在展示个人简介时,配置了一个“显示已保存的HTML内容”的功能,它会对数据库读出的内容再做一次实体解码,然后再交给富文本渲染器渲染。也就是说,同一个字符串,从用户输入到最终显示,总共发生了两次实体解码。第一次解码发生在服务端过滤前,第二次解码发生在浏览器渲染前。这两次解码的顺序,把服务端的白名单完全架空了。

4.2 解码顺序不一致:攻击链推演

安全测试时如果想验证这个怀疑,思路是构造一段在第一次解码后“看起来很安全”、在第二次解码后才变成真实标签的内容。换句话说,你需要在输入里多一层实体编码,让它第一次解码后变成<之类的实体,第二次解码后才还原成真正的标签。这样一来,服务端过滤看到的仍然是一堆实体文本,自然放行;真正到了浏览器里,编辑器自作多情地又解了一次码,那串实体变成可执行的标签,存储型XSS成立。

我第一次发现这个链路时,其实是在审查编辑器源码而不是黑盒测试。因为黑盒测试需要你先意识到“系统里存在两次解码”,否则你根本想不到要去提交两层实体编码的内容。后来我把这类问题统一命名为“解码不对称”,排查时核心思路就是顺着一份用户内容,看它完整经过几次解码、几次编码、几次过滤,把每次变换的语义都记录下来。任何一个“多解了一次”或“少编了一次”的位置,都是可疑点。

4.3 富文本安全修复清单

这个案例的修复,我给团队的方案分了三层:

  • 统一解码编码语义:在服务端明文存储标准化格式之前,明确只允许一次实体解码;任何后续展示环节不得再次解码。
  • 前端渲染前再做一次清洗:即使编辑器支持富文本,也引入成熟的HTML清洗库在渲染入口做最终过滤,相当于防线后置。
  • 关闭不必要的编辑扩展:把所有编辑器扩展全部过一遍,只保留必要功能,避免出现因扩展自定义渲染逻辑而引入新解码路径。

我还额外提醒了一点:富文本编辑器的安全责任不能只落在服务端。服务端白名单再严格,也管不到前端组件内部那些“看似友好的”实体解码行为。只要前端渲染逻辑里还有一次多余的解码,服务端过滤就是白做。所以,富文本场景下,一定要把“服务端白名单”和“前端最终清洗”两者同时抓好,缺一不可。

5. 案例四:CSP配置失误让XSS影响被放大

第四个案例不是某个具体的漏洞点,而是一个让我印象深刻的CSP配置问题。背景是某后台系统上线前做了安全加固,安全响应头里加上了Content-Security-Policy: default-src 'self'。看到这个头,不少人的第一反应是“终于安全了”。但如果再往下看一行,就会发现script-src 'self' 'unsafe-inline'。这一句,等于把CSP最重要的保护直接关掉了。

5.1 “有CSP”不等于“安全”

CSP的核心思想,是给浏览器一套“允许执行的脚本来源白名单”。如果页面只允许来自自己域名的脚本文件,那么即使攻击者能在某个输入点注入<script>,浏览器也会因为脚本来源不在白名单里而拒绝执行。但如果你加上了unsafe-inline,内联脚本就被放行了。攻击者注入的标签如果走内联方式执行,CSP就管不住。换句话说,CSP配置得不好,可能比没有CSP更容易给团队造成“已经在防护”的错觉。

我在这个系统里看到的现象就是这样。团队确实启用了CSP,但因为某些历史页面依赖内联脚本,运维图省事直接放开了unsafe-inline。结果系统里任何一个原本只能改DOM的低危XSS点,因为CSP没有拦截内联脚本,直接升级成了可以执行任意JS的高危问题。影响范围从“页面被改”变成“数据可能被读取、操作可能被冒用”。

5.2 配置CSP时常见的三个翻车点

  • 第一个翻车点:为了兼容旧页面放弃unsafe-inline限制,或者一路放开到'unsafe-eval',让CSP形同虚设。
  • 第二个翻车点:只配script-src,忘了管object-src、base-uri、frame-ancestors这些指令。攻击者可以借<object>加载插件、改页面基准地址、或者被第三方页面嵌进iframe,CSP覆盖面不够。
  • 第三个翻车点:上线时没有先使用Content-Security-Policy-Report-Only模式收集违规报告,而是直接全量强制执行。结果某个部门页面出现功能异常,又不得不紧急回滚,最后策略形同虚设。

我比较推荐的是nonce方式:服务端每次响应都生成一个一次性随机数,只给可信的<script>标签加上nonce,内联脚本没有nonce直接拦截。这样既保留内联脚本的兼容性,又不会把门全部打开。上线前先用报告模式跑一段时间观察,确认没有明显的兼容问题,再从报告模式切到强制模式。

5.3 从数据流视角理解纵深防御

这个案例给我最大的启发,是“防御也要分层”。你不可能保证每个XSS输入点都绝对安全,所以CSP这类外部兜底才显得重要。但反过来,CSP兜底也不意味着输入编码可以放松。正确的姿势是:输入校验做纵深,输出编码做兜底,渲染层做隔离,CSP在最外围再包一层。四层之间不是替代关系,而是互相托底。看一个系统是否安全,我会从这四层分别问自己一遍:哪一层最薄弱?如果这一层失效,后面还有没有兜底?

6. 挖洞之外:把单点经验变成团队方法论

聊完具体案例,我想把这些经验往上提一层。因为只记录“我又挖到一个洞”价值有限,真正有用的是让这些经验沉淀成一套可持续的方法论。我在前面写的每个案例都有各自不同的触发点,但它们背后的逻辑其实高度重复。

6.1 四个案例的共同点

第一个共同点:漏洞都不是扫描器报出来的,更多来自代码审计、功能走查和对报错的敏感度。扫描器擅长发现“模式”,不擅长理解“语义”。解码顺序、上下文编码、第三方组件的隐式渲染,这些都需要人来判断。第二个共同点:都与开发者的安全假设有关——以为转义了尖括号就安全,以为白名单过滤就安全,以为有CSP就安全。安全不安全,要看完整的数据流动,而不是某个环节的局部操作。第三个共同点:修复都遵循同一条线——输入校验做纵深、输出编码做兜底、渲染层做隔离、CSP在外部再加一层防护。

6.2 一份能落地的XSS排查清单

我后来把经验整理成了一张团队内部通用的排查清单,每次上线前过一遍,命中率比想象中高:

  • 用户可控数据是否进入HTML标签文本?如果有,确认实体编码覆盖了<、>、&。
  • 用户可控数据是否进入HTML属性值?如果有,确认属性值引号被编码,且属性为双引号包裹。
  • 页面是否使用富文本编辑器?如果有,确认在过滤后不再发生二次解码,渲染入口有最终清洗。
  • 是否上线了CSP?确认没有开启unsafe-inline,并覆盖了object-src、base-uri等指令。
  • 前端是否读取location、hash、postMessage等输入?如果有,确认这些输入不会以原始形态进入危险渲染点。
  • 有无第三方组件接收字符串配置并渲染到DOM?如果有,默认当作不可信输入处理。

这张清单最大的价值不是“查漏”,而是让团队在开发阶段就意识到输出点在哪。每次功能上线,前端工程师拿着清单过一遍,安全团队再抽样复核,比依赖最后阶段一次渗透测试靠谱得多。

6.3 人工审计和自动化工具的配合方式

自动化扫描器当然不能扔。我的使用习惯是:用扫描器覆盖“已知常见场景”,比如反射型XSS、基础DOM XSS、CSP头缺失;把人工精力集中在扫描器覆盖不到的地方,比如富文本渲染链路的双重解码、第三方组件内部的隐式渲染、跨页面消息通信带来的输入面。每次扫描器发现一个洞,我们除了修复它之外,还要反推“这个洞如果被绕过会走哪条路径”,用这个反推结果去扩展下一步的审计范围。这样自动化和人工就不是互相替代,而是互相放大对方的价值。

上面这些经验不是一天形成的。我早期做安全测试,见到innerHTML就兴奋,一天能报出好几个问题,但有一些只是开发者已经知道但没排期修的常规项。后来我把注意力从“哪里能弹窗”转向“为什么开发者觉得这里安全”,才真正找到那些修复起来能让整个系统更稳的洞。XSS说到底是信任边界的缺口,浏览器把一切可解析的内容都当成页面渲染,而应用层却以为自己在处理纯文本。理解这条信任边界,再回头看前端每一个输出点,判断速度会快很多。

最后再分享一点个人习惯:每次挖到有意思的XSS,我不急着撤离,而是会在本地搭一个最小化还原的Demo,把这个漏洞从输入到输出的完整路径画下来。这个Demo通常只有几十行,但它帮我记住了漏洞的“形状”。之后再看别的系统,只要出现类似形状的数据流,我的直觉就会自动报警。这篇笔记作为我安全笔记序列里的第106篇,也按这个习惯留了底,希望你也能从这些案例中找到一些可以迁移到自己项目里的判断思路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进