资讯动态

Spring Boot Whitelabel错误页与SpEL注入:从信息泄露到RCE的审计与加固

发布时间:2026/10/14 1:38:38 来源:尧图企业网站定制
1. 从一个默认报错页说起为什么Whitelabel Error Page值得单独拎出来讲但凡用过Spring Boot的人几乎都见过那个白底黑字、写着Whitelabel Error Page的默认错误页。大多数人第一次看到它的反应是哦报错了然后顺手去翻日志。但如果你稍微停一下盯着这个页面多看两眼会发现一个有意思的事实这个页面本身是Spring Boot在没有任何自定义错误处理时自动兜底渲染出来的它把请求路径、状态码、异常类型、甚至异常消息都直接吐给了浏览器。问题就出在这里。当应用在生产环境里把异常信息原样暴露给外部时攻击面就被悄悄放大了。而Spring生态里有一个绕不开的组件——SpELSpring Expression LanguageSpring表达式语言它被大量用于Value、条件注解、缓存key、安全表达式等场景。当用户可控的输入和SpEL表达式求值这两件事在同一个执行路径上相遇远程代码执行RCE的链条就具备了成立的前提。这篇内容不是要教你去做任何破坏性的事情而是站在防御和代码审计的视角把这条链路拆开揉碎Whitelabel Error Page是怎么被触发的、SpEL的求值机制到底发生了什么、为什么某些写法会把表达式求值暴露到外部、以及在真实项目里应该怎么排查和加固。适合有Spring Boot基础、想深入理解表达式注入原理的后端开发也适合做安全审计的同学当作一份排查清单来用。需要先明确一点下面所有演示都基于本地搭建的模拟项目和虚构参数目的是理解机制任何针对真实系统的未授权测试都是不被允许的。2. Whitelabel Error Page的渲染链路与信息暴露边界2.1 默认错误处理是怎么被触发的Spring Boot的错误处理核心是BasicErrorController它实现了ErrorController接口默认映射到/error路径。当请求过程中抛出异常或者返回了非2xx的状态码容器会把请求转发forward到/error由BasicErrorController决定返回什么内容。它内部会根据请求的Accept头做内容协商如果客户端接受text/html就走errorHtml方法渲染Whitelabel页面如果接受application/json就走error方法返回一个JSON结构。这个JSON结构里通常包含timestamp、status、error、message、path这几个字段。关键点在于message字段。在Spring Boot 2.3之前server.error.include-message的默认行为是把异常消息包含进去的2.3之后默认改成了never但很多老项目、或者显式配置成always的项目依然会把异常消息原样返回。这就是信息泄露的第一个口子。2.2 哪些信息会被吐出来我把一个模拟项目跑起来故意触发几种不同的异常观察Whitelabel页面和JSON返回的差异。整理成一张表更直观配置项默认值2.3设为always后的表现风险点server.error.include-messagenever返回异常message泄露内部路径、SQL片段、类名server.error.include-binding-errorsnever返回参数校验错误详情泄露字段名与校验规则server.error.include-stacktracenever返回完整堆栈泄露框架版本、依赖结构server.error.include-exceptionfalse返回异常类全名辅助攻击者判断技术栈从审计角度看include-message和include-stacktrace是最需要盯紧的两个。堆栈里往往带着org.springframework.expression.spel.standard.SpelExpressionParser这类类名等于直接告诉对方我这里有SpEL求值。2.3 错误页为什么容易成为入口很多人会问错误页不就是展示信息吗怎么就成了入口逻辑是这样的——错误页本身不执行代码但它是一个回显点。当应用某处存在SpEL注入但求值结果没有直接返回给用户时攻击者需要一个能把结果带出来的通道。异常消息回显就是最经典的带外通道之一。举个虚构的例子假设某个接口把用户传入的排序字段拼进了SpEL表达式求值失败时抛出SpelEvaluationException异常消息里包含了表达式片段和求值上下文。如果include-messagealways这个异常消息就会出现在Whitelabel页面上。攻击者通过构造不同的表达式观察回显内容的变化就能逐步推断出可用的类和方法最终拼出一条能执行命令的表达式。所以Whitelabel Error Page在这里扮演的角色是放大器它把原本藏在日志里的信息搬到了HTTP响应里。3. SpEL求值机制从表达式字符串到方法调用的完整路径3.1 SpEL的基本求值流程SpEL的求值入口是ExpressionParser接口最常用的实现是SpelExpressionParser。一次完整的求值大致经过这几步调用parser.parseExpression(expressionString)把字符串解析成Expression对象。这一步会做词法分析和语法分析生成AST抽象语法树。调用expression.getValue(context)或getValue(rootObject)传入求值上下文。SpelExpression内部通过SpelExpressionState驱动AST节点逐个求值最终返回结果。关键在于第1步parseExpression接受的是一段字符串。只要这段字符串里包含T(...)、new ...、方法调用等语法解析出来的AST就具备执行能力。SpEL本身是一门图灵完备的表达式语言它设计上就允许调用静态方法、构造对象、访问属性。3.2 那些危险但合法的语法SpEL里几个需要重点关注的语法特性T(全限定类名)引用一个类型比如T(java.lang.Runtime)。拿到类型之后就能调用它的静态方法。new 类名(...)直接构造对象。方法调用链T(java.lang.Runtime).getRuntime().exec(...)这种链式调用。#{}与${}在Spring的占位符体系里${}走的是PropertyPlaceholderHelper#{}走的才是SpEL。但某些场景下两者会被混用导致本以为是属性替换的地方实际执行了表达式。我实测过在纯SpEL环境下T(java.lang.Runtime).getRuntime().exec(...)这类表达式是能被正常解析和执行的。这不是漏洞这是SpEL的设计能力。问题在于谁有权决定这段表达式字符串的内容。3.3 求值上下文决定了攻击面大小同样是SpEL求值上下文不同危害差别很大。我把它分成三档完全可控表达式字符串直接来自用户输入且没有做任何白名单过滤。这是最严重的情况。部分可控表达式模板固定用户输入只作为参数拼进去。比如#userInput这种如果用户输入里带了T(...)依然可能逃逸。不可控表达式完全由代码内部生成用户无法影响。这种情况基本安全。审计的时候第一步永远是定位parseExpression的调用点然后回溯它的参数来源。参数来源决定了这一档属于哪一类。3.4 一个容易被忽略的细节SimpleEvaluationContextSpring提供了SimpleEvaluationContext它的设计目的就是限制SpEL的能力——默认不允许类型引用、不允许构造对象、不允许调用任意方法。如果代码里用的是SimpleEvaluationContext而不是StandardEvaluationContext那么T(...)和new ...这类语法会被直接拒绝。我在模拟项目里对比过两种上下文的行为// 危险StandardEvaluationContext 允许完整SpEL能力 ExpressionParser parser new SpelExpressionParser(); Expression exp parser.parseExpression(userInput); String result exp.getValue(new StandardEvaluationContext(), String.class); // 相对安全SimpleEvaluationContext 限制类型引用与方法调用 Expression exp2 parser.parseExpression(userInput); String result2 exp2.getValue( SimpleEvaluationContext.forReadOnlyDataBinding().build(), String.class);用StandardEvaluationContext时T(java.lang.System).getProperty(java.version)能正常返回版本号换成SimpleEvaluationContext同样的表达式会抛异常。这个差异在审计时是重要的判断依据——看到StandardEvaluationContext就要提高警惕。4. 表达式注入是怎么一步步走到RCE的4.1 注入点的常见来源在实际项目里SpEL注入点往往藏在一些看起来人畜无害的地方。我梳理了几类高频场景动态排序/动态查询把前端传来的sortField、filterExpression直接拼进SpEL用于动态构造查询条件。规则引擎/风控表达式业务方配置的规则字符串运行时用SpEL求值。缓存key生成用SpEL表达式动态生成缓存keykey的模板来自配置或用户。权限表达式PreAuthorize里用了#{}如果参数拼接不当也可能被利用。消息模板/通知模板模板内容支持表达式用户可编辑模板。这些场景的共同点是开发者希望灵活于是引入了表达式求值但忽略了灵活和可控之间的边界。4.2 从信息泄露到命令执行的递进我把这条链路拆成四个阶段方便理解每一步在做什么第一阶段确认求值存在。构造一个简单的算术表达式比如11如果返回2说明输入确实被当作SpEL求值了。这一步是探针。第二阶段探测可用类型。尝试T(java.lang.Math).random()这类无害的静态方法调用确认类型引用是否被允许。如果返回一个随机数说明T()语法可用。第三阶段寻找执行原语。在允许类型引用的前提下寻找能执行系统命令的类。这一步是危害升级的关键。第四阶段构造回显或带外通道。命令执行的结果需要被带出来。如果错误页回显了异常消息可以直接把命令输出拼进异常消息否则需要借助其他通道。需要强调的是这四个阶段在真实攻防里是攻击者的思路而我们做防御的人要做的是在第一阶段就把路堵死——让用户输入根本无法进入parseExpression。4.3 为什么过滤关键字往往不够很多团队的第一反应是加黑名单过滤Runtime、exec、ProcessBuilder这些词。我实测下来这种做法的绕过成本很低字符串拼接T(java.lang.Runtime)如果过滤发生在拼接之前就失效。反射调用通过Class.forName动态加载绕开直接的类型引用。大小写与编码变形t(java.lang.Runtime)在某些解析器实现下可能被规范化。利用其他可执行原语不一定是Runtime.exec也可能是ScriptEngineManager加载脚本引擎。黑名单的本质问题是枚举不完。SpEL能触达的类和方法太多靠列举禁止项永远有漏网之鱼。正确的思路是白名单——只允许特定的、安全的表达式子集或者干脆不用SpEL处理用户输入。4.4 一个模拟的完整链路复盘我在本地模拟项目里复现过一条链路这里把关键节点抽象出来参数已做虚构处理假设有个接口/api/query接收一个orderBy参数内部代码大致是String expr #root. orderBy; // orderBy 来自请求参数 ExpressionParser parser new SpelExpressionParser(); Expression exp parser.parseExpression(expr); Object value exp.getValue(someObject);正常请求传name表达式变成#root.name返回对象的name属性。如果传的是构造过的字符串表达式就变成了#root.构造内容。由于用的是StandardEvaluationContextT()语法可用攻击者就能在#root后面接上类型引用把求值导向命令执行。这条链路的成立需要三个条件同时满足用户输入进入表达式字符串、使用StandardEvaluationContext、异常消息可回显。缺任何一个危害都会大幅降低。这也正好对应了三个加固方向。5. 代码审计视角怎么快速定位项目里的SpEL风险点5.1 静态搜索的关键字清单做审计时我习惯先用全局搜索把可疑点捞出来。下面这些关键字按优先级排列关键字说明风险等级parseExpressionSpEL解析入口高SpelExpressionParser解析器实例化高StandardEvaluationContext完整能力上下文高getValue(求值调用中Value(#{注解里的SpEL中PreAuthorize权限表达式中ExpressionParser接口引用中SimpleEvaluationContext受限上下文低相对安全搜索到parseExpression之后重点看它的参数是不是拼接了外部输入。如果参数是常量字符串基本可以排除如果是变量就要继续回溯变量的来源。5.2 数据流回溯的方法回溯的核心问题是这个变量能不能被外部控制我一般沿着调用链往上追直到追到Controller层的参数绑定、或者配置文件的读取点。几个判断要点参数是否来自RequestParam、PathVariable、RequestBody、RequestHeader。是否来自数据库里用户可写字段。是否来自配置中心且配置项有管理界面可编辑。中间是否经过了白名单校验。如果一条链路从HTTP入口一路无过滤地走到parseExpression那就是明确的高危点。5.3 动态验证的思路静态审计只能定位嫌疑点最终确认还得靠动态验证。在本地环境里我通常这样做在parseExpression调用处打断点观察传入的表达式字符串。构造一个带探针的请求看断点处捕获到的字符串是否包含我的输入。用一个无害的表达式比如T(java.lang.System).getProperty(os.name)测试求值是否成功。观察异常处理路径确认异常消息是否会回显到响应里。这四步走完一个点是否可利用基本就清楚了。整个过程都在本地模拟环境完成不涉及任何真实系统。5.4 审计时容易漏掉的间接调用有些SpEL求值不是显式调用parseExpression而是藏在框架内部。比如Spring Cache的key属性支持SpEL如果key的生成逻辑里混入了用户输入。Spring Security的PreAuthorize、PostAuthorize。Spring Integration的消息路由表达式。某些ORM框架的动态查询构造。这些间接调用点不会出现在你搜索parseExpression的结果里需要结合框架特性单独排查。我的经验是先把框架用到的所有表达式配置项列出来逐个确认其内容来源。6. 加固方案从配置收敛到表达式沙箱6.1 先把错误页的信息口子收紧这是成本最低、见效最快的一步。在application.properties或application.yml里显式配置server: error: include-message: never include-stacktrace: never include-binding-errors: never include-exception: false whitelabel: enabled: falsewhitelabel.enabled: false会关掉默认错误页配合自定义的ErrorController返回统一的、不含内部信息的错误结构。这样即使应用内部有异常外部也拿不到有价值的回显。注意关掉Whitelabel页面不等于错误处理就安全了。如果自定义的错误处理器仍然把exception.getMessage()返回给前端信息照样泄露。关键是控制返回给外部的内容和记录到日志的内容之间的边界。6.2 用SimpleEvaluationContext替换StandardEvaluationContext如果业务确实需要SpEL优先使用受限上下文。SimpleEvaluationContext提供了几种构建方式// 只读数据绑定不允许方法调用和类型引用 SimpleEvaluationContext ctx1 SimpleEvaluationContext.forReadOnlyDataBinding().build(); // 允许读写属性但仍限制类型引用 SimpleEvaluationContext ctx2 SimpleEvaluationContext.forReadWriteDataBinding().build(); // 自定义允许的方法集合 SimpleEvaluationContext ctx3 SimpleEvaluationContext .forPropertyAccessors(myPropertyAccessor) .withMethodResolvers(myMethodResolver) .build();实测下来forReadOnlyDataBinding能挡掉绝大多数注入尝试因为T()和new都会被拒绝。代价是某些依赖完整SpEL能力的业务逻辑需要改写。6.3 表达式白名单与输入校验如果连受限上下文都不够用就必须在输入层做白名单。思路是不解析用户输入的表达式而是让用户从预定义的选项里选。比如排序字段前端传的是枚举值NAME、CREATE_TIME后端映射到固定的表达式而不是把用户字符串直接拼进去。// 不推荐直接拼接 String expr #root. userInput; // 推荐枚举映射 MapString, String allowedFields Map.of( name, #root.name, createTime, #root.createTime ); String expr allowedFields.get(userInput); if (expr null) { throw new IllegalArgumentException(非法字段); }白名单的关键是默认拒绝——不在允许列表里的一律拒绝而不是在黑名单里才拒绝。6.4 最小权限与运行环境隔离即使表达式注入被利用如果应用进程本身权限很低危害也能被限制。几个实践建议应用进程不要用root运行用专用的低权限账号。容器化部署时开启只读文件系统、限制能力集。敏感操作走独立的服务主应用不直接持有高权限凭证。对Runtime、ProcessBuilder这类类的使用做代码层面的管控比如通过自定义SecurityManager虽然在新版本JDK里已被弃用但思路仍值得参考。这些措施不能替代输入校验但能作为纵深防御的一层。6.5 依赖版本与框架配置的定期核查SpEL相关的安全问题有一部分是通过框架版本升级修复的。定期核查Spring Framework和Spring Boot的版本关注官方安全公告是必要的日常动作。同时检查项目里是否有显式开启include-messagealways这类为了方便调试的配置遗留到了生产环境——这种配置在开发期很常见上线时忘了改就是隐患。7. 我在实际排查中踩过的几个坑第一个坑是以为关了Whitelabel就万事大吉。有次我在模拟项目里把whitelabel.enabled设成false结果自定义错误处理器里写了response.getWriter().write(e.getMessage())信息照样泄露。后来才明白错误页只是表现形式真正要管的是哪些信息允许出网。第二个坑是只盯着显式的parseExpression。有个模拟场景里SpEL求值藏在缓存注解的key表达式里我一开始全局搜parseExpression完全没搜到绕了半天才反应过来是框架内部调用的。从那以后我养成了习惯先把项目用到的所有支持表达式的注解和配置项列一遍。第三个坑是低估了字符串拼接的绕过能力。早期我写过一版黑名单过滤把Runtime、exec都禁了结果测试时用反射调用轻松绕过。这件事让我彻底放弃了黑名单思路转向白名单和受限上下文。第四个坑是忽略了异常消息里的表达式片段。有些框架在抛SpelEvaluationException时会把出错的表达式原文带进消息里。如果这个异常被回显等于把攻击者的输入原样返回方便对方调试payload。所以异常消息的脱敏处理不能只做一半。8. 给开发和审计同学的一份检查清单把上面的内容浓缩成一份可执行的清单方便在项目里逐项核对生产环境的server.error.include-message、include-stacktrace、include-binding-errors是否都设为never。whitelabel.enabled是否关闭自定义错误处理器是否做了信息脱敏。全局搜索parseExpression、SpelExpressionParser、StandardEvaluationContext逐个确认参数来源。所有SpEL求值点是否优先使用SimpleEvaluationContext。用户输入进入表达式前是否有白名单校验是否默认拒绝。框架层面支持表达式的注解缓存key、权限表达式等是否排查过。应用进程权限是否最小化容器是否做了隔离。依赖版本是否在受支持的安全版本范围内。日志里是否记录了完整的表达式内容便于事后追溯但日志本身不对外暴露。这份清单不是一次性的建议纳入代码评审的检查项。表达式注入这类问题往往不是某个人的疏忽而是灵活优先的开发习惯长期积累的结果。把边界定清楚比事后补救划算得多。最后分享一个我在本地测试时常用的小技巧在parseExpression调用前后各加一行日志记录表达式字符串和求值结果注意日志级别和脱敏。这样在联调阶段就能直观看到哪些请求触发了求值、传进来的内容长什么样。很多问题在开发期就能被发现不用等到上线后被动应对。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑