资讯详情

EL表达式注入原理与防御:从运行机制到代码审计排查

📅 2026/10/20 16:58:30 | 华诺云谱 👁 阅读
EL表达式注入原理与防御:从运行机制到代码审计排查
我在审代码时经常看到这样的写法从请求里拿到一个参数拼进一段字符串再扔给某个表达式求值引擎去处理。很多人第一反应是“这不就是SQL注入吗”但当我告诉他这里跑的其实是一段EL表达式时对方通常会愣一下然后反问一句EL还能注入能而且危害一点都不小。表达式语言Expression Language简称EL原本是Java Web里用来简化页面数据访问的一套规范最常见的形态就是JSP里的${user.name}这类写法。它的设计目标是“让模板更好读、让取值更省事”可正因为这套机制天然具备反射调用、属性访问、甚至方法调用的能力一旦用户输入被拼进了表达式本身攻击者就能借力打力通过精心构造的表达式去访问内部对象、读取敏感数据极端情况下甚至能进一步扩大影响面。本文我会从EL的运行机制讲起把注入的成因、完整攻击链、修复思路和排查方法一条条拆开适合正在做Java Web开发的工程师、写代码审计的同学以及所有对Web安全感兴趣的读者。1. 先搞清楚EL表达式是怎么“工作”的1.1 一段最普通的模板背后发生了什么假设你在一个管理后台的列表页看到这么一行模板代码当前登录人${currentUser.nickName}你觉得它只是“从对象里取了个字段”但实际上它等价于执行了这么一段逻辑Object result currentUser.getNickName();这里最微妙的地方在于模板里写的不是Java代码而是一串字符串。等到页面渲染时容器才把这串字符串交给表达式引擎引擎把currentUser.nickName解析成一条“先取变量currentUser再调用其getNickName()方法”的属性链然后一路反射调用下去拿到最终结果。如果你在某个页面里用过${order.amount 100 ? 大单 : 普通单}你其实已经在用EL的逻辑运算、条件判断能力了。它远不止“取属性”这么简单而是一套迷你编程语言。1.2 EL的核心机制表达式编译、值解析与属性访问EL从字符串到执行结果中间大致有这么几步第一步是表达式编译。引擎比如标准实现中的ExpressionFactory拿到${...}里的字符串做词法分析、语法分析最后生成一棵表达式的语法树。注意这一步通常是动态发生的也就是“字符串什么时候来语法树什么时候建”。第二步是值解析。引擎会调用一个叫ELResolver的组件去解析表达式中的每个变量和属性。一个完整的环境里会有一串ELResolver各管一摊BeanELResolver通过反射调用JavaBean的getter方法MapELResolver从Map里按键取值ListELResolver、ArrayELResolver按下标取值ResourceBundleELResolver从资源文件取值ScopeELResolver从page/request/session/application作用域取变量。第三步是属性级联访问。表达式a.b.c会先解析a再对结果解析b再对结果解析c。这个机制意味着只要你能让引擎拿到一个对象你就可以顺着它的getter一路往下“摸”摸到多深取决于你输入了多少层属性链。1.3 安全视角EL的“容错设计”其实是在给攻击者铺路EL有个很要命的特性解析失败时默认返回null而不是抛出异常。比如你写${user.address.zipCode}而user是null多数实现不会直接崩掉而是安静地返回null。这个设计初衷是让模板在数据不完整时也能正常渲染可到了安全场景里它就成了一把保护伞——攻击者可以放心地反复尝试各种属性链猜错了也不会有异常暴露最多看到页面某处多了一个null。另外一个容易被忽略的点是标准EL对方法调用是有支持某些规范版本、某些实现下的。不光是取属性还可能直接调用对象的方法。比如某个对象暴露了一个eval方法表达式里就可以写成${bean.eval(...)}。这已经不是“越权读字段”的范畴了而是真正具备“代码执行”的想象空间。这也是为什么EL注入会被归到比较严重的那一档漏洞里——表面看只是拼了个字符串实际等于把一部分代码执行权交给了输入方。2. 为什么会出现EL注入——典型触发场景与成因拆解2.1 三种常见的“事故现场”我在实际代码里见到最多的EL注入点不是JSP页面而是各种动态规则、配置平台和通用查询模块。举几个很典型的例子。场景一营销系统的“条件配置”某个优惠系统提供了一个“自定义规则”的功能运营同学可以在后台配置一条类似“满1000减200”的规则。开发为了把规则做成“万能”的直接设计成存一段EL表达式例如String rule ${order.amount 1000 ? discount_200 : no_discount};如果这个rule是从配置中心读取的固定串那还好说。但很多系统为了让规则能“热更新”会做一个从接口参数或后台表单里接收条件文本的入口然后拼进EL上下文求值。用户提交了什么引擎就解析什么这就把口子完全打开了。场景二报表模块的动态列某个报表系统支持用户自己定义“计算列”比如“毛利率 (收入-成本)/收入”。开发一拍脑袋决定用EL来实现公式计算String formula ${ userInput }; Object result evaluate(formula, dataMap);用户输入收入、成本这些业务字段名当然没问题但如果他输入的是applicationScope.user.password这种系统内部路径呢很多EL实现里作用域对象是直接暴露在表达式环境中的攻击者完全可以借这个入口去翻内部数据。场景三自定义工作流里的“审批条件”流程引擎里经常要判断“金额大于5000需要总监审批”有的系统把判断条件实现成表达式字符串存在数据库里。一旦这个条件可以由流程发起人提交那EL注入的点就落在最上游了。2.2 EL注入、SQL注入、命令注入有何不同很多入门同学分不清这几类注入先用一个表格捋清楚注入类型注入目标拼接的是什么危害核心SQL注入数据库查询语句SQL片段越权读写数据库命令注入系统Shell系统命令在服务器上执行命令EL注入表达式求值引擎EL表达式访问Java内部对象、调用方法它们本质上是同一个问题的不同表现把不可信输入放进了本该由开发者控制的“代码区域”导致输入被当成代码来执行。SQL注入里输入被拼进SQL语句EL注入里输入被拼进表达式内容。但EL注入有一个让攻击者特别舒服的特点它是在Java运行环境内执行的。这意味着表达式里能直接访问到的对象类型、作用域、以及Java反射能触达的东西都会被带进攻击视野。不需要猜数据库结构不需要考虑语句语法只需要顺着对象图遍历即可。2.3 为什么“设计得越优雅”越容易踩雷有个比较讽刺的现象EL注入通常出现在被设计得很“灵活”的系统里。开发为了未来扩展方便主动引入“表达式化配置”结果把本来应该写死在代码里的逻辑一股脑开放给了输入方。这类问题不是“开发不小心写错了一个地方”而是“架构层面主动选择了风险”。所以排查EL注入时不只要看代码更要看这块功能的产品定位——凡是标注了“支持自定义表达式”“支持脚本化配置”“支持公式配置”的模块都是重点怀疑对象。3. 一次完整的EL注入过程推演提示本段所有操作均基于授权测试环境示例中的系统为虚构的内部Demo。未经授权对实际系统进行测试是违法行为。3.1 阶段一找到表达式解析点先要确定攻击面在哪。假设我们在做一个代码审计看到了这样一个接口PostMapping(/calc) public MapString, Object calc(RequestBody CalcRequest req, HttpSession session) { // 某些实现中会把session、application放入EL上下文 MapString, Object env new HashMap(); env.put(data, businessDataService.getById(req.getDataId())); String expression ${ req.getExpression() }; Object result elEvaluator.evaluate(expression, env); return Collections.singletonMap(result, result); }接口接收一个expression参数把它原样放在${}里交给EL引擎求值然后把结果返回给前端。这已经是一个非常标准的EL注入点了。注意这个接口能回显表达式求值结果——回显能力对漏洞确认和利用都是极其重要的没有回显会困难得多。3.2 阶段二探测与信息收集先验证表达式是不是真的被执行。最基础的试法expression7*7正常返回{result:49}如果返回了49说明表达式被求值了。然后测试属性访问能力expression.getClass().getName()如果引擎支持方法调用且字符串对象可被访问就可能返回java.lang.String。这一步其实已经在通报一个坏消息表达式环境里能拿到Java对象而且能调用方法。接下来要看表达式环境里到底暴露了什么。很多实现会把request、session、application这些对象作为隐式对象放进上下文。可以尝试expressionapplicationScope.getClass().getName()或者直接尝试拿到运行时相关的信息。标准Java运行时里通过某些对象的class属性可以一路摸到ClassLoader、系统属性等敏感数据。很多EL实现对这类访问没有做限制等于把内部结构的门牌号直接告诉攻击者。注意我在这里刻意不展开完整“利用链”因为某些链在特定版本和特定环境下确实可以推进到更严重的影响但这类细节必须限制在授权测试框架内讨论。理解“能访问对象图、能反射、能方法调用”这一事实本身已经足够支撑后续防御工作的展开。3.3 阶段三构造属性链EL的优雅与危险是同源的——属性可以级联访问。攻击者会不断尝试构建更深的属性链去找那些能读到敏感数据的getter。举个例子如果表达式环境里暴露了一个名为user的对象攻击者可能会尝试expressionuser.username expressionuser.role.name expressionuser.password你可能会说user这个Bean未必有password字段啊。但问题是BeanELResolver是走反射的它会调用所有公共getter。如果这个Bean有getPassword()、getToken()、getAccessKey()这类方法那不管页面有没有展示过表达式都能拿到。这给了我们一个非常重要的防御视角Bean上所有公共getter都是表达式攻击面的一部分不是只有模板里用到的那些才算。更隐蔽的情况是有些Bean里藏着“嵌套炸弹”比如某个配置对象内部有一个getRuntime()或getClassLoader()之类的getter表达式只要多写一层就能摸到运行时对象。这也是为什么EL注入防起来比SQL注入麻烦——你很难在写代码时预见每一个getter被链式调用后的效果。3.4 阶段四影响评估与利用边界从防御和评估角度EL注入的典型影响包括敏感数据读取通过对象图遍历读取会话信息、内部配置、数据库连接参数等。逻辑绕过如果表达式参与鉴权判断攻击者可以构造表达式使校验永远通过。方法调用带来的更大风险在支持方法调用且环境中存在危险对象的场景下有可能进一步扩大影响。业务功能滥用通过操纵求值结果导致规则命中异常、报表数据失真。要注意EL注入不一定会造成“服务器被控制”这种肉眼可见的严重破坏。很多实际案例里攻击者只是静默地拿到了不该拿的数据。这类漏洞的危害评估不能只看是否getshell要看数据暴露面。3.5 授权与边界我必须把这句话放这儿本文的所有思路都应该用在代码审计、漏洞自测、安全评估这些授权场景里。如果你是在维护别人的系统或者在网上看到个带表达式参数的接口就想试一把请立刻停手。安全测试的红线是授权没有授权的一切测试行为都是违法。写安全博客、做安全研究第一课不是怎么攻击而是知道什么能碰、什么不能碰。4. 修复与加固把EL注入从根上堵住4.1 铁律用户输入永远不能拼进表达式语法空间修复EL注入最优先的永远是这一条重新设计数据流让用户输入进入表达式之前先变成一个“值”而不是变成“语法”。拿前面那个接口来说如果业务上只需要接收一个字段名来动态取值那就别把用户输入直接包进${}。可以改成这样预先定义好允许访问的字段白名单用户只能传字段编号后端把它映射成固定的EL表达式。// 反例直接把用户输入拼进表达式 String expression ${ req.getExpression() }; // 正例字段白名单映射 MapString, String allowedFields Map.of( 1, data.totalAmount, 2, data.orderCount ); String expr ${ allowedFields.getOrDefault(req.getFieldCode(), 0) };这段代码的精髓在于白名单之外的输入根本进不了表达式的语法树。用户传1、2拿到的是预定义的属性链传其他任何东西只会得到一个恒定的0。攻击者没有机会注入语法。4.2 如果一定要支持“用户自定义表达式”有些产品确实需要用户配置公式、配置规则这不是不能做但必须以加固后的方式做。下面几条是我在实践中验证过的按重要性排序。第一显式锁定ELResolver集合。默认的BeanELResolver反射能力强但你可以自己实现一个受限解析组件只允许解析你注册过的Bean类并且对属性访问做白名单。比如一个安全解析器里先判断目标对象是否在白名单类型范围内再去决定是否放行getter调用不在白名单里的直接返回null。第二严格控制表达式环境里暴露的对象。不要把application、session这些隐式对象直接塞进上下文。只把当前业务需要的数据对象放进去做到“最小暴露”。MapString, Object env new HashMap(); env.put(order, orderService.getById(orderId)); // 不要把 session、application、classLoader 等放进来第三限制方法调用的可用性。如果EL实现支持方法调用最好通过配置文件或解析层面将其关闭或者在表达式语法树构建后做静态检查拒绝那些包含getClass、forName、invoke等危险方法的表达式。第四引入表达式求值超时与次数限制。EL表达式如果写得非常复杂可能造成大量计算开销。虽然不是安全核心但在公开入口处设置求值超时和长度上限能起到一定防护作用。4.3 纵深防御错误信息、依赖更新与权限隔离修复漏洞不能只看漏洞点本身还要把周边环境一起补上。错误信息要统一处理。EL表达式解析失败时默认可能返回null但某些实现在特定配置下会把异常栈打到响应里。如果攻击者能看到PropertyNotFoundException: xxx not found on type com.xxx.SystemConfig那就等于拿到了类名、属性结构等一系列敏感信息。正确做法是对外统一返回“计算失败”具体异常记到日志里让开发排查。依赖要跟踪更新。EL表达式引擎的实现在不同版本里暴露的默认对象、方法调用支持程度都可能变化旧版本往往有更多未收口的能力。建议定期做依赖版本检查及时升级到修复已知问题的版本。权限上要落实“最小运行”原则。承载EL求值的服务应当使用最小权限账号运行数据库账号只授权当前业务需要的库表。这样即使某个表达式真的造成了严重后果也能把横向扩散的半径压到最小。4.4 修复后的回归验证修复完别急着上线把之前用来探测的那些请求原样重放一遍。我一般按这个清单来验证传7*7确认不再返回计算值传.getClass().getName()确认无回显或返回安全提示传白名单外的字段编号确认返回默认值而不是解析用户输入传超长、包含特殊字符的表达式确认接口没有异常回显、没有500错误合法的业务功能回归一遍确保正常场景没被误伤。修复不是“把表达式过滤一下再拼”而是从源头切断输入进入语法的路径。所有黑名单、过滤、转义方案我都见过被绕过的白名单和结构隔离才是真正牢靠的。5. 从代码里把EL注入捞出来——排查清单5.1 静态排查先抓这几个关键词拿到一个项目代码先别急着从头读直接在工程里搜下面这些特征${出现在Java代码字符串拼接里而不是模板文件里时异常醒目ExpressionFactory、ValueExpression、getValue、evaluate出现这些调用的地方就是表达式求值的“引擎车间”ognl、spel、jexl、mvel、groovy这些不是标准EL但属于同一大类“表达式/脚本引擎”如果用户输入能进入它们的求值入口同样按本思路排查自定义配置接口凡是接口参数里有“表达式”“公式”“规则文本”“脚本内容”这类字段的全部列为重点。找到候选点之后逐个回答三个问题用户输入或者外部数据有没有直接或间接地进入表达式字符串表达式环境中暴露了哪些对象表达式求值结果会不会回显给调用方三个问题如果都是“是”那基本可以确定这是个高风险点。哪怕暂时没被利用也要按漏洞记录上报。5.2 动态验证在测试环境重放探测请求静态排查有了目标接下来要进测试环境做动态确认。我用得最顺手的流程是这样的先用一个简单的算术表达式探测“是否真的在求值”expression23如果返回5继续探测“对象访问能力”expressionapplicationScope再逐步尝试访问对象属性。整个过程建议用抓包工具把请求记录下来方便后面整理成漏洞报告。有一个容易被忽略的地方不同接口对表达式的包装方式不同。有的接口会帮用户把输入包成${...}有的会包成#{...}这是另一种表达式语法还有的接口不要求用户输入表达式本身的符号只要求输入内容。你测试时不能只按一种格式打要结合后端代码里实际的拼接规则来构造请求。5.3 工具链建议代码层面可以用常见的静态扫描工具SAST跑一遍把“用户输入流向表达式求值方法”这类数据流问题识别出来。不过这类规则不一定覆盖所有自定义的EL包装函数所以扫描结果只能作为线索还得人工确认。接口层面用代理类工具手工改包测试非常高效。在已有的业务请求里把参数值替换成探测表达式观察返回。如果系统里引入了很多第三方组件建议做依赖审计确认当前版本是否存在已知的表达式解析相关漏洞。排查完一轮之后把这些内容整理进安全报告漏洞点位置、数据流路径、可读数据样例、修复建议、验证结果。报告写清楚开发修复才有据可依。6. EL注入常见问题速查表疑问原因解决办法为什么传7*7返回49说明有漏洞吗如果输入被拼进表达式并求值说明存在EL注入点按第4节的方案重新设计数据流停止拼接表达式返回null是不是说明不可利用不一定。EL默认对解析失败返回null探测不到异常不代表不可利用结合回显差异、响应时间、日志等多通道判断只做特殊字符过滤行不行过滤、转义通常都能被绕过表达式语法组合太多换成白名单或结构隔离不依赖过滤用户输入进入的是一些表达式框架的封装安全吗不安全凡是字符串进表达式引擎的路径都同等看待统一按EL注入思路排查修复后业务要支持自定义公式怎么平衡可以配置“预定义公式”只允许用户选不允许用户输入表达式文本用白名单映射公式编号表达式求值环境里暴露session影响大吗大session里常有认证信息、用户身份等敏感数据最小化暴露对象按业务需求单独传参把这张表发给开发同学很多问题当场就能对齐。我自己的经验是EL注入的修复最大的阻力不是技术而是产品上“自定义表达式”这个看似灵活的需求。只要产品愿意把“自定义表达式”改成“选择预定义规则”开发难度和安全风险都同步下降一大截。我在实际项目里见过太多因为“想做得灵活一点”而引入的风险。表达式引擎本身不是洪水猛兽它用对了能极大提升系统扩展性但它一旦接收了用户输入事情就变了味——你的业务逻辑就不再完全由你控制了。写代码之前多问一句“这个输入需要进入语法层吗”省下的可能是一整轮安全整改的成本。如果你现在手头正好在维护类似的“规则配置”“公式计算”“动态查询”功能我的建议是今天就去看一眼那几行拼接表达式字符串的代码把用户输入的流向画出来。大概率会有意外收获而这种收获越早发现越值钱。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑