☰
XSS代码审计实战手册:三种形态、数据流追踪与修复验证
2026/10/17 18:31:11 网站建设 项目流程

做代码审计这几年,被问得最多的一个问题就是:XSS漏洞在代码里到底怎么看?很多朋友拿着扫描器一顿跑,跑出一堆疑似点,但不知道哪些是真漏洞、哪些是误报,也说不清这些点为什么能打、怎么修才彻底。这篇文章就从代码审计的角度,把我自己常用的排查思路和实战经验完整拆一遍,适合刚接触安全审计的开发小伙伴,也适合想把手工审计补起来的测试同学。文章会覆盖XSS的三种形态、审计切入点、真实案例、绕过识别、修复验证,基本是拿到一个项目就能对照操作的手册式内容。

1. 审计前先搞懂XSS的三副面孔

1.1 反射型、存储型、DOM型到底差在哪

在代码审计里,XSS不能当成一个笼统的"往页面塞脚本"来理解。三种形态的触发机制、审计方法、修复位置都不一样,混在一起看只会越看越乱。反射型和存储型的共同点是前端输入在服务端处理后再输出回页面,区别在于反射型不落库、一次性回显,存储型则会被持久化保存,每次访问都会触发。DOM型严格来说是前端问题,整个渲染过程都在浏览器里完成,服务端代码几乎看不到攻击痕迹。判断某个点是哪一种,最直接的办法是看数据流在哪里被"停住"再"放出",服务端echo出来的基本属于前两种,纯JS操作innerHTML或document.write的属于DOM型。

为了更直观对比,可以列一个表:

维度反射型存储型DOM型
数据落库否是否
触发位置入口页面回显展示页/列表页前端JS执行路径
审计重心服务端输出入库+所有输出点前端源码
典型危害钓鱼、盗取一次性数据持久化控制、挂马绕过服务端过滤

这里需要强调的是,很多新手把注意力全放在"找危险函数"上,看到echo、print就兴奋,看到innerHTML就紧张。这没问题,但不完整。危险函数只是输出侧的一个节点,真正决定漏洞成立与否的,是输入到输出之间的整条路径上有没有做合理的编码或校验。我在实际项目中见过太多这样的情况:同一个输出点,对A场景是漏洞,对B场景因为上游加了统一编码反而不是漏洞。所以看XSS一定要顺着数据流看,不能只看某个函数名。

还有一点值得单独提醒:DOM型XSS在代码仓库里的搜索难度比前两种高得多,因为前端代码通常是压缩过的,变量名全是a、b、c,搜索innerHTML倒是能搜到,但真正的入口可能在几十层回调后面。审计时不要只在服务端代码里翻,要记得把前端构建产物也拉进来一起看。

1.2 从审计视角重新梳理攻击链路

从攻击者的视角,一次XSS的完整链路是:构造恶意输入、输入被应用接收、应用在某处输出未经过滤、浏览器解析执行。代码审计的视角其实是把这条链反过来走:先看哪里接收了外部输入,再看这些输入一路流到了哪些输出点,最后判断输出点所在的上下文有没有做正确防护。这几年我用的就是"输入点清单+输出点清单+数据流追踪"三个动作的组合,基本能覆盖90%以上的XSS问题。

为什么要这么拆?因为直接全局搜危险函数会漏掉一个很关键的场景:数据可能先在某个函数里被处理过、被编码过、被截断过,再输出到页面。这时候你只看到输出点,是判断不了漏洞是否存在的。反过来,只盯着输入点也不行,有些输入根本不经过后端,纯前端JS里读了个location.hash就拼进DOM里,服务端代码完全无感知。所以审计的核心是构建数据流,而不是罗列函数。

在正式开始审计前,我习惯先给目标项目画一张"输入输出地图":输入源包括URL参数、表单、请求头(User-Agent、Referer、Cookie)、文件上传的文件名等;输出源包括服务端渲染模板、接口返回JSON、前端DOM操作、window.open、动态脚本加载等。这张地图不用画到像素级,能让自己在追踪时有一个清晰的上下文就够了。之后再逐个输入点往下游追,效率会高很多。

2. 我常用的审计切入点:从危险函数到数据流追踪

2.1 输入侧:先抓接收外部参数的入口

先说输入侧。不同技术栈的输入点长得不一样,但核心思路是一致的:找所有从外部拿数据的地方。拿我熟悉的环境举例,PHP的话是$_GET、$_POST、$_REQUEST、$_COOKIE、$_FILES、$_SERVER;Java的Servlet是request.getParameter、getHeader、getCookies;Python后端常见request.args、request.form、请求头解析;前端JS则是location.search、location.hash、document.referrer、window.name、URLSearchParams。这些地方是外部数据的入口,也是整个审计流程的起点。

为什么先把入口全列出来?因为代码审计和渗透测试不一样。渗透测试是从外到内、边测边看;代码审计是从内到外,你得先把所有"进水口"摸清,才能知道哪些数据可能流进代码深处。我自己习惯的做法是:第一步先静态搜出所有入口点,第二步给每个入口点建一条编号记录,第三步逐个往下游追。追的时候不要把注意力局限在同一个文件里,跨文件、跨模块的调用很常见,尤其在前后端分离的项目里,前端入口(URL参数)和后端入口(API路由)要对得上才行。

这一节还需要提一个新手经常忽略的输入点:文件名和上传文件内容。很多项目的"上传头像"功能会回显文件名或图片meta信息,这些字段如果直接拼接进HTML标签属性里,同样是XSS高发区。另外,请求头里的Referer和User-Agent也会被一些运维后台打印在日志查看页里,这是一个比较隐蔽的反射型输入点。审计时一定不要只看业务参数,头字段、文件字段都要纳入检查范围。

2.2 输出侧:重点盯HTML、JS、CSS三块渲染位置

输出侧是XSS落地的位置,也是审计的重头戏。很多审计新手在这个环节容易犯一个毛病——只盯着尖括号看,却不关心数据落在什么上下文里。我会按渲染上下文把输出点分成三类:HTML上下文、JS上下文、CSS/属性上下文。严格来说还有URL上下文,实际中我会把它并入属性上下文一起看。上下文不同,攻击面完全不同,检查方法也完全不同,先分清楚类型再动手,后面判断真伪时才能有依据。

HTML上下文的典型表达式是<div><?php echo $var; ?></div>,这类位置的目标是构造标签闭合或注入新标签,常见利用是<script>、<img onerror>、<svg onload>。审计要点是看输出位置在标签内还是标签外,在标签内就要考虑属性值能不能被打破,比如<a href="<?php echo $url; ?>">,如果能注入s onclick=,就形成了属性型XSS。

JS上下文更危险,因为注入点直接落在脚本里。典型代码是<script>var data = '<?php echo $data; ?>';</script>或前端代码eval(response.data)。这类位置即使输出侧做了HTML编码,也可能因为引号闭合逃逸到JS语境里执行。审计时重点看单引号、双引号是否被处理、是否使用了JSON序列化并正确设置了编码方式。

CSS和属性上下文容易被忽略,比如style="background-image:url(<?php echo $url; ?>)",在URL里可以使用javascript:协议,在CSS里还可以用expression(老IE场景,现在基本不成立)。现代浏览器下CSS注入面变小,但属性上下文的href、src、action仍然需要检查协议白名单。

我在审计时会把这三类上下文分别在笔记里列成一个表,标注各自的危险字符和绕过面,比如HTML上下文重点看尖括号和标签构造、JS上下文重点看引号和换行、属性上下文重点看引号冒号空格和协议关键字,这样后面做真伪判定时会有依据,不至于看到一个尖括号就拍板说是漏洞,也不至于漏掉JS字符串场景的逃逸点。

2.3 数据流追踪:每个变量都问一句"它从哪来、到哪去"

有了一张输入输出地图,接下来就是真正花时间的活:数据流追踪。这个过程可以用一句话总结——拿到一个变量,先问它从哪来,再问它到哪去,中间每一步都看一眼有没有做过滤、编码、类型转换。我通常在IDE里用全局搜索功能配合"跳转到定义"来完成追踪,一条链路从入口到输出一般要经过三到五个文件。

数据流追踪有几个好用的抓手:第一,变量命名。虽然生产代码的变量名经常表意不清,但业务代码里$title、$name、$content这类变量通常能暗示数据性质,方便溯源。第二,第三方库的转换函数。很多人爱用strip_tags、htmlspecialchars、escape这类函数,搜索这些函数能快速定位潜在的过滤点,再顺着这些过滤点往回找输入。第三,ORM和数据层的存取。判断入库时是否做了转义、出库时是否做了还原,这直接影响存储型XSS能否成立。

追踪时还有一个重要习惯:记录"处理点"。就是数据流中途被截断、编码、替换、强转的位置。举个例子,某个输入进入时是字符串,中途被intval()强转成整数,那就算最后输出到HTML里也没办法注入标签,这就是一个天然的安全节点。反过来,如果数据流中途被某些自定义函数做了"安全过滤",但这个函数本身有绕过(比如只替换了<script>,没替换<img onerror>),那这个处理点就是漏洞点。审计的精细度就是体现在这些地方。

3. 实战案例拆解:某后台系统的三处XSS漏洞

这一段进入正题。案例基于早期在某公司的一次安审,项目代号是模拟项目X,对外是一个后台管理系统,技术栈是Java前后端不分离,模板用JSP,前端有部分原生JS。

3.1 第一处:搜索框回显未编码,反射型XSS

先看第一处。后台的"订单搜索"功能在页面上提供了一个搜索框,用户输入关键词后,请求会带着参数keyword发给服务端,服务端查询数据库后把结果列表连同搜索关键词一起回显到页面顶部,提示"您搜索的关键词是:xxx"。这个回显语句在JSP里大致是这样:

<div class="search-tip">您搜索的关键词是:<%= request.getParameter("keyword") %></div>

问题非常明显:keyword参数直接从请求里取出,没有经过任何编码就输出到HTML上下文中。测试时在搜索框里输入<svg onload=alert(document.domain)>,提交后发现页面顶部出现了可执行的SVG标签,弹窗成功触发,属于标准反射型XSS。

这里我要补充一个审计经验:当时我第一时间看的不是JSP这行代码,而是这个搜索功能对应的Controller。因为很多项目会在Controller层或拦截器里配一个全局过滤器,对所有请求参数统一做转义,如果存在全局过滤,那JSP里这行未编码的输出就未必能打。实际审计结果是该后台的全局拦截器只处理了登录态校验,没有任何参数过滤,所以反射型XSS成立。这也说明,审计时不能只看单点代码,要确认项目里是否存在全局防线,全局防线是什么配置,是否对所有路由生效。很多误报就产生于"只看到单点输出,没看清全局过滤"。

修复上,最简单的方案是在输出位置使用StringEscapeUtils.escapeHtml4做HTML上下文编码,并设置ENT_QUOTES处理单双引号。改完之后再用同样的payload验证,脚本被当作纯文本显示在页面上,不再执行。

3.2 第二处:用户昵称存储后未过滤,存储型XSS

第二处问题在"用户评论管理"模块。该后台允许客服人员录入用户反馈的评论内容,用于留档和后续查看。评论内容录入后保存在数据库的comment_content字段,并在列表页和管理详情页展示。问题就出在录入接口和展示页面都没有任何过滤和编码:录入接口只做了字段长度校验,展示页面直接通过EL表达式${comment.content}输出到HTML里。

存储型XSS和反射型的区别在这里体现得很明显:payload不是马上触发,而是先入库,再在后台每一次打开列表时都触发。当时模拟payload为<img src=x onerror=confirm(1)>,客服在录入页面提交后,再打开评论列表,弹窗在页面渲染时立即执行,管理员每次查看都会被触发。

审计这类问题时要特别注意两点。第一,存储型XSS的输入点往往离输出点很远,可能是同一个系统,也可能是不同系统,比如评论内容录在后台,展示却在前台网页,这就需要追踪完整链路。第二,展示页面不止一个时,只要有一个输出点漏了编码,漏洞就还在。当时这个系统除了JSP页面展示,还有一个导出Excel的功能,如果导出时没有对单元格内容做公式注入和HTML标签处理,同样可能变成另一个攻击面。所以在评审修复方案时,我要求不仅修JSP展示页,所有读取该字段的输出端都要统一处理。

修复的核心思路是对存储数据进行"输出编码,输入的语义交给白名单校验"。HTML上下文用HTML编码函数处理,同时防范前端展示时被转成富文本的情况。这个案例里最终采用了展示层统一转义,并在录入接口做了白名单去标签,双管齐下。

3.3 第三处:URL参数拼进innerHTML,DOM型XSS

第三处比较隐蔽,是一个"在线帮助中心"页面。页面上面有几个Tab,点击Tab时前端JS会从location.search里读取tab参数,然后动态更新右侧内容区域的HTML。相关代码如下:

function initTab() { const params = new URLSearchParams(window.location.search); const tab = params.get('tab') || 'basic'; document.getElementById('tabContent').innerHTML = '<h3>' + tab + '</h3>'; }

这个点没有经过任何服务端,攻击者构造URLhelp.jsp?tab=<img src=1 onerror=alert(document.cookie)>,页面加载后innerHTML直接拼接执行。属于DOM型XSS,服务端代码和日志里完全看不到恶意payload,传统WAF也不容易拦住。

审计这类问题的关键是要把前端代码纳入审计范围。很多从服务端审计出身的人容易忽略前端构建产物,但在前后端分离越来越普遍的今天,DOM型XSS的占比在明显上升。我当时是用全局搜索innerHTML、document.write、insertAdjacentHTML、eval、setTimeout('...')这几个关键函数找到这个点的,然后再逆向追踪这些位置的数据来源。

修复时要注意,对DOM型XSS单纯加服务端编码是无效的,因为整个操作发生在客户端。正确做法是数据在插入DOM前用前端编码函数处理,或者改用textContent、innerText代替innerHTML拼接纯文本,如果必须展示HTML,就得用经过白名单校验的富文本方案。这个案例里因为Tab名称本来只是标题,直接改成textContent赋值,彻底消除了注入面。

4. 代码审计中的绕过与误报排查

4.1 常见过滤绕过思路:编码、标签拆分、事件属性

先说绕过。代码审计中经常会看到开发者试图用黑名单或简单替换来处理XSS,这种处理方式本身没有错,错在只做了一层被动防御。黑名单少了任何一个标签、任何一类事件属性、任何一种编码形式,攻击者都可能钻空子。审计时看到这类防护,不要急着判定为安全,要顺着输入、过滤函数、输出位置三个环节走一遍,测试几类典型的绕过思路。

第一类思路是编码绕过。黑名单如果只匹配了原始字符串<script>,攻击者可以换成全角字符、HTML实体、URL编码,或者大小写混合<ScRiPt>。如果过滤函数在服务端只做了一次str_replace,攻击者可以传<scr<script>ipt>让替换后的结果重新拼成原始标签。审计时要留意过滤函数是否递归执行、是否处理了HTML实体,以及字符集设置是否可能导致解析差异。

第二类是标签和事件属性绕过。开发者可能把所有标签都过滤了,或者只过滤了script标签,但XSS不一定需要script,<img onerror>、<svg onload>、<a href="javascript:">、<iframe srcdoc>都可以执行脚本。审计时不要只盯着标签列表,要确认过滤后的字符环境是否允许属性注入。我还见过一个过滤器把onerror替换成on_error,却没处理onload等兄弟事件,绕过空间非常大。

第三类是上下文切换带来的绕过。同一个变量可能同时进入了HTML上下文、JS上下文和URL上下文,开发者只在一个上下文做了编码,其他上下文没处理,攻击者找到另一个入口就能打。审计时对同一数据源的所有输出点要全量检查,不能看两三个位置安全就认为整个变量安全了。

实战中我的复盘建议是:过滤规则可以被绕过时,不要花太多时间去证明每一种绕过Payload,因为黑名单是被动式的,今天堵住一个,明天还有新的变体。审计报告里只要明确指出"该位置依赖黑名单过滤,存在系统性绕过风险"就够了,修复应该直接换成输出编码加白名单方案。

4.2 如何区分真漏洞和误报

误报识别能力是实力分水岭。审计工具扫出来的结果,经常一类是真漏洞、一类是要扩散阅读确认的疑似点、一类是完全不成立的误报。我自己判断一个XSS点是否为真漏洞,基本按三步走。

第一步:确认输入是否可控且未经过滤。如果输入点在页面里写死的常量,或者数据源是服务端内部配置,那就不是外部可控的XSS。如果输入可控但上游有intval()、json_encode()或白名单配置做了类型限定,也可能不构成注入。有些变量名虽然叫userId,实际上值却是可控字符串,命名会骗人,务必看具体赋值来源。

第二步:确认输出点是否有上下文编码。输出点的编码看的是渲染上下文的安全边界。有些代码在输出时确实调用了escapeHtml,但没用ENT_QUOTES,导致双引号仍然逃逸,这种情况在HTML属性上下文仍然可打。有些调用了JSON序列化,但前端用eval而不是JSON.parse接收,也可能存在JS上下文注入。审计时要把上下文和编码函数匹配起来看。

第三步:手动构造payload做验证。对于HTML上下文,尝试<>字符是否被原样输出;对于属性上下文,尝试双引号"是否能提前闭合;对于JS上下文,尝试单引号、换行等字符是否被原样输出。用这些标记字符做"语义探测",比直接跑一个弹窗payload更容易判断出边界状态。确认边界能被突破后,再换成实际可利用的payload做最终验证。

做完这三步仍然存疑的点,我会在报告里标记为"待确认"而不是直接判定漏洞,并附上我验证时的具体请求与输出片段。这样既不给开发埋雷,也不让漏洞蒙混过关。误报处理的关键就是:审查要严谨但不过度。

5. 修复方案与验证方法

5.1 输出编码是底线,白名单校验是关键

遇到XSS,最稳妥的修复组合是"输出编码保底+输入侧白名单限制"。这两者是互补关系:输出编码保证即使有数据进来,渲染出来也是纯文本;输入白名单保证进入业务逻辑的数据符合预期格式,降低脏数据入库面。

输出编码要根据上下文选函数。HTML正文和标签属性,优先用HTML实体编码,注意开启引号转义;JS字符串上下文,用JSON序列化或专门的JS转义;URL上下文,用URL编码加协议白名单(只允许http、https、mailto等)。编码函数选错,防御效果等于零。比如在JS上下文里只做了HTML实体编码,攻击者仍然可以通过\u003c这类JS unicode形式绕过,审计时就需要注意。

输入白名单不是万能的,但某些字段天然适合。例如数字ID、手机号、邮箱、日期,直接用正则或类型校验限定格式,根本不给注入余地。对于富文本内容,绝对不能简单用黑名单过滤,要采用白名单标签加属性校验,允许的标签就那么几个,比如b、i、a、img,其余全部剥除。富文本这块建议直接引主流的富文本清洗库,然后加一层输出编码,避免自己造轮子。

修复落地时我强烈建议在项目层面做统一封装,而不是让开发在每个输出点手写编码函数。手写容易出现"这个点转了、那个点忘了"的情况。统一封装后和Code Review联动,审计只需要确认调用点是否都用了封装函数,覆盖率会高很多,也更容易做自动化检测。

5.2 CSP能不能当救命稻草

很多团队听到XSS就想着上CSP,这没问题,但CSP不能当成唯一的修复手段。CSP是纵深防御的一层,它的作用是"即使XSS发生,降低被利用的影响",而不是"让XSS不发生"。而且CSP配置本身有大量坑,配置不当可能完全形同虚设。

列几个常见的坑:第一,script-src允许了'unsafe-inline',那内联脚本照样执行,CSP等于没设;第二,允许了'unsafe-eval',那么eval、setTimeout('...')这些照样能运行;第三,如果站点有JSONP接口或者CDN域名回源配置不当,攻击者可能利用这些合法来源执行脚本。还有CSP的降级问题,老版本浏览器不支持或者部分支持,会让策略失效。审计CSP配置时,要对着实际页面脚本来源逐一验证。

我的建议是,CSP作为修复方案中"加一层"的部分,配置从严格模式开始调,目标是不允许unsafe-inline和unsafe-eval,必要时通过nonce或hash放行少量内联脚本。配合HttpOnly Cookie还可以降低Cookie窃取的风险。但底线仍然放在输出编码上,两者并存才是一个完整的修复闭环。

5.3 修完怎么验证:回归测试清单

修复之后的验证环节,很多团队是一测了之:随便输入一个<script>alert(1)</script>,看到不弹窗就算过了。这个验证力度说实话太弱,因为只验证了最经典的标签场景,属性闭合、JS逃逸、富文本过滤这些更隐蔽的场景根本没覆盖到。我做XSS修复回归时有一个固定清单,每次改完都照着走一遍,可以分享出来供参考。

  1. 基础探测:向对应输入点提交特殊字符集<>"'(){};:/?=&#,确认输出位置对这些字符的处理是否符合编码预期。
  2. 上下文验证:根据输出位置是HTML正文、标签属性、JS字符串、URL之一,分别测试对应的闭合payload。例如属性上下文测试" onclick=alert(1)是否逃逸,JS上下文测试' ); alert(1)//是否逃逸。
  3. 原型payload验证:选常见payload,包括<script>、<img src=x onerror>、<svg onload>、<a href="javascript:">,逐一确认页面渲染为纯文本或主动拦截。
  4. 存储场景验证:修复后再次提交恶意内容入库,再到所有展示页面检查,不仅看列表页,还要看详情页、导出文件、邮件模板等所有输出端。
  5. DOM场景验证:对前端代码检查innerHTML、document.write、eval等关键函数周边是否还有未改完的数据拼接。

比起一个接一个手动测试,如果项目有自动化测试框架,建议把上面这几条固化成交替用例,后续每次版本迭代跑一遍。XSS的修复不是一次性的,业务代码持续迭代,代码评审时要继续盯新引入的输出点,否则过两个版本就可能在新的输出点复发。前段时间我又做了一次例行复查,果然在一个新增的批量导出接口里看到了同样的拼接写法。所以修复验证要常态化,不能只做一次性整改。

最后再分享一句我的体会:XSS代码审计说到底不是一门"找函数"的技术,而是一门"看数据流"的手艺。工具可以帮你列出候选点,但能不能判断真伪、能不能看清上下文、能不能提出让开发愿意改的修复方案,靠的还是对代码、对业务、对整条链路的理解。建议刚入门的朋友别急着把每个输出点都当成漏洞,先花时间把一个项目从头到尾的数据流摸透,再回头看扫描结果,会有完全不一样的感觉。如果大家在审计中遇到什么有意思的误报或绕过案例,欢迎一起交流。

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

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

立即咨询