QLExpress黑名单绕过实战:从SSRF到文件读取的Java表达式引擎漏洞挖掘
1. 项目概述从CTF到实战的思维跃迁很多刚接触安全的朋友都是从CTFCapture The Flag开始的。那些精巧的题目像一个个设计好的谜题引导我们学习各种漏洞原理和利用技巧。但当你真正面对一个生产环境的应用尤其是那些集成了复杂业务逻辑和第三方组件的系统时你会发现CTF里的“标准答案”往往不灵了。这次要聊的就是一个典型的例子如何在一个使用了QLExpress表达式引擎、并且设置了看似严密黑名单的Java应用里找到突破口实现SSRF服务器端请求伪造和文件读取。QLExpress是什么简单说它是一个由阿里开源的动态脚本引擎允许你在运行时执行一段字符串形式的Java代码。想象一下一个配置中心允许管理员输入一段表达式来动态计算某个配置项的值或者一个风控系统允许运营人员编写灵活的规则来判断风险——QLExpress就是干这个的。它的强大在于灵活性但这份灵活如果交给了不可信的用户输入就成了巨大的安全隐患。我遇到的这个目标正是在一个“规则引擎”的配置入口处使用了QLExpress来处理用户输入的表达式并且开发者显然意识到了风险加入了关键词黑名单来试图防御。这就像给一扇危险的门上了一把简易的挂锁我们的任务就是找到撬开或者绕过这把锁的方法。从CTF到实战最大的区别在于“上下文”。CTF题目的漏洞点往往孤立、明显而实战中漏洞可能深埋在层层业务包装之下并且伴随着各种自定义的过滤和校验。这次经历就是一次将CTF中积累的协议利用、黑名单绕过技巧在复杂的实战业务逻辑中寻找应用场景的完整过程。它不仅关乎技术更关乎对目标系统架构和代码逻辑的深度理解。2. 核心漏洞场景与QLExpress黑名单机制解析2.1 目标功能点与攻击面发现目标系统是一个企业内部的后台管理系统其中一个核心模块是“动态规则引擎”。管理员可以在这里创建规则规则的核心是一个“条件表达式”系统会根据这个表达式的结果True/False来决定后续的业务流程。表达式的编辑界面提供了一个文本框旁边写着“支持QLExpress语法”。这是一个非常清晰的攻击面。QLExpress作为一个功能强大的引擎默认就具备执行任意Java代码的能力。如果没有任何防护用户输入一段Runtime.getRuntime().exec(calc);服务器可能就直接弹出计算器了当然实际生产环境可能没有GUI。所以有经验或者说有基本安全意识的开发者一定会做防护。我首先进行了最简单的测试输入了java.lang.System.exit(0)。页面返回了一个错误“表达式包含非法关键词”。果然有过滤。接下来的任务就是搞清楚这个黑名单到底拦了什么以及有没有办法绕过去。2.2 黑名单策略的逆向与局限性分析通过一系列模糊测试和错误信息反馈我逐步摸清了黑名单的大致范围。开发者主要防御的是以下几类直接命令执行拦截了Runtime、ProcessBuilder、exec、cmd、/bin/bash等明显的关键词。敏感类调用拦截了java.lang.Class、forName、getMethod、invoke等用于反射的关键词。文件操作拦截了java.io.File、FileInputStream、read等。网络操作拦截了java.net.URL、URLConnection、openConnection、HttpURLConnection等。看起来非常全面对吧开发者可能觉得把能想到的危险类和函数都禁了就安全了。这正是许多初级乃至中级开发者在设计黑名单时的经典思维误区试图枚举所有“坏”的东西而不是定义什么是“好”的。QLExpress的威力在于它是一个完整的脚本引擎。黑名单就像一张渔网网眼的大小决定了能拦住什么。开发者只堵住了几条他们认识的大鱼如Runtime但海洋是辽阔的。我的绕过思路核心基于两点利用QLExpress自身特性与Java生态的庞大QLExpress支持完整的Java语法和大量的内置对象、函数。即使禁用了java.net.URL我们是否可以通过其他方式发起网络请求即使禁用了java.io.File我们是否可以通过其他类库读取文件字符串拼接与编码混淆黑名单通常是简单的字符串匹配。能否将关键词拆散、变形、编码使其在表达式解析时还原但绕过过滤检查实操心得黑名单测试方法不要盲目猜测。我写了一个简单的Python脚本将疑似关键词如Runtime,runtime,RUNTIME,Class,class以及它们的字符拆解组合如Runtime批量提交到测试接口通过返回的错误信息“非法关键词” vs 其他语法错误来精确绘制出黑名单的边界。这个过程就像探雷需要耐心和系统的方法。3. 第一阶段突破实现无java.net包的SSRF既然直接使用java.net.URL被禁我的第一个想法是寻找替代品。在Java庞大的标准库和常见的第三方依赖中发起HTTP请求的类远不止一个。3.1 寻找替代的HTTP客户端首先我检查了目标应用的依赖通过报错信息中偶尔带出的版本信息以及一些已知的接口特征。发现它使用了Apache HttpClient 4.x的版本。这是一个非常常见的HTTP客户端库。关键在于QLExpress在执行时能够访问到当前Java进程类路径Classpath中的所有类。我尝试了以下表达式import com.fasterxml.jackson.databind.ObjectMapper; new ObjectMapper().readValue({a:1}, java.util.Map.class)成功返回了{a1}。这说明QLExpress的import功能可用并且能访问到项目依赖这里是Jackson。这给了我巨大信心。接下来我尝试导入HttpClientimport org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; def client HttpClients.createDefault(); def get new HttpGet(http://169.254.169.254/latest/meta-data/); def response client.execute(get); EntityUtils.toString(response.getEntity())提交后再次触发“非法关键词”告警。看来开发者也不傻把org.apache.http也加入了黑名单。这条路暂时受阻。3.2 利用内置对象与脚本引擎特性QLExpress有一个强大的特性它预置了一些“上下文对象”并且允许在表达式中直接使用它们。同时它支持脚本内的函数定义和调用。我通过查阅QLExpress的官方文档和测试发现即使不import也可以通过完整的类名来访问类前提是这个类能被类加载器加载。我尝试了另一种方式使用Java原生的java.net.HttpURLConnection不行URL在黑名单里。那其他库呢比如java.net.URI配合java.net.http.HttpClientJava 11目标环境是Java 8此路不通。这时我想到了利用java.lang.ClassLoader来动态加载类。虽然Class、forName可能被禁但ClassLoader的loadClass方法呢测试了一下loadClass果然也在黑名单里。但是获取ClassLoader的途径不止一种。任何一个对象都有getClass().getClassLoader()。我可以先找到一个允许使用的类实例。我构造了这样一个表达式// 先获取一个合法对象的ClassLoader def cl .getClass().getClassLoader(); // 尝试加载HttpClient类这里使用字符串拼接绕过org.apache.http的关键词检查 def className org.apach e.http.impl.client.HttpClients; // 注意直接调用loadClass可能被禁但我们可以利用QLExpress的反射调用语法 // 实际上QLExpress对这样的动态调用支持有限。需要换思路。直接动态加载并实例化过于复杂且容易触发黑名单。我转换思路是否存在更冷门的、未被列入黑名单的HTTP库3.3 终极绕过利用java.net.InetAddress与Socket进行端口探测SSRF不一定非要发起完整的HTTP请求。有时探测内网端口和服务的存在性就是非常有价值的信息。java.net.Socket类可能没有被禁因为很多合法业务也会用到Socket通信。我测试了new Socket()成功了黑名单果然遗漏了它。于是我构造了一个用于内网端口探测的表达式import java.net.Socket; def host 127.0.0.1; def port 22; def timeout 2000; def socket new Socket(); try { socket.connect(new java.net.InetSocketAddress(host, port), timeout); Port port is OPEN on host; } catch (Exception e) { Port port is CLOSED or filtered on host : e.getMessage(); } finally { if (socket ! null) try { socket.close(); } catch (Exception e2) {} }这个表达式成功执行并返回了Port 22 is OPEN on 127.0.0.1。这证实了SSRF的可能性。虽然它不能直接获取HTTP响应体但已经可以用于内网资产发现。更进一步我可以编写一个循环批量探测常见端口80, 443, 8080, 6379, 3306等。注意事项Socket探测的局限性协议无关Socket连接成功只代表该端口开放并接受TCP连接无法知道上面跑的是什么服务HTTP/Redis/MySQL。无回显这种方式是“盲SSRF”我们只能通过连接成功与否即表达式返回的字符串是成功还是异常信息来判断状态无法直接读取服务返回的banner或数据。可能触发安全设备大量快速的Socket连接很容易被主机防火墙或IDS/IPS检测到。3.4 升级攻击实现带数据提取的SSRF端口探测不够我需要读取响应。既然Socket可用我能否手动实现一个简单的HTTP客户端理论上可以但QLExpress脚本中处理字节流和协议解析会比较繁琐。有没有更取巧的办法我重新审视了依赖。除了Apache HttpClient还有一个极其常见、甚至Spring Boot默认就带的库RestTemplate属于Spring框架。我尝试在表达式中直接使用new org.springframework.web.client.RestTemplate()。结果令人惊喜——没有触发黑名单开发者可能认为Spring框架的类是“安全的”。于是构造最终的SSRF利用表达式// 利用Spring的RestTemplate发起HTTP请求 import org.springframework.web.client.RestTemplate; def template new RestTemplate(); def url file:///etc/passwd; // 尝试文件读取 try { def response template.getForObject(url, String.class); // 注意直接返回可能包含换行符在Web界面显示可能不完整可以取前100字符 response ! null ? response.substring(0, Math.min(response.length(), 100)) : Null Response; } catch (Exception e) { Error: e.getClass().getName() - e.getMessage(); }提交后表达式返回了Error: java.lang.IllegalArgumentException - URI is not absolute。这说明RestTemplate默认不支持file://协议。但这没关系我们的目标是HTTP SSRF。将其替换为http://169.254.169.254/latest/meta-data/AWS元数据服务地址一个经典的SSRF测试目标或者内网地址http://192.168.1.1/admin。为什么RestTemplate能绕过黑名单类名冷门RestTemplate不像URL或HttpClient那样是安全教程中的“常客”容易被开发者忽略。业务相关性目标系统本身可能就是一个Spring Boot应用开发者潜意识里认为项目自身的依赖是“可信的”从而在制定黑名单时产生了盲区。黑名单的固有缺陷再次印证了黑名单无法穷尽所有风险点。4. 第二阶段突破实现无java.io包的文件读取实现了SSRF后文件读取是另一个关键目标。黑名单拦截了java.io.File等类。我们的思路需要更加曲折。4.1 利用SSRF读取本地文件最直接的方法就是利用刚才已经实现的SSRF能力结合file://协议。但上面提到RestTemplate默认不支持。我们需要寻找一个支持多协议的资源访问工具。Java中有一个java.net.URL类但它被禁了。然而其底层实现依赖于URLConnection和不同的URLStreamHandler。有没有其他方式创建URLConnection我尝试了Class.forName被禁。此路不通。换个角度Spring框架提供了Resource接口来抽象各种资源文件、类路径资源、URL资源等。对应的有一个ResourceLoader。我尝试了import org.springframework.core.io.UrlResource; def resource new UrlResource(file:///etc/passwd); def inputStream resource.getInputStream(); // 需要将InputStream转换为字符串这里又需要IO操作...要读取InputStream又需要用到java.io包下的类如InputStreamReader、BufferedReader这些很可能也在黑名单上。4.2 利用运行时编译与类加载技巧这是一个比较高级的技巧。QLExpress虽然禁了直接的文件操作和某些类但它本质上是一个代码执行引擎。我们能否在表达式内部动态编译并加载一段“无害”的代码这段代码里包含了我们需要的文件读取逻辑QLExpress支持定义函数和类但功能有限。更直接的想法是利用Java的ScriptEngineManager如果环境允许但目标环境不一定有。另一个思路是利用Java的ToolProvider.getSystemJavaCompiler()进行内存编译这需要javax.tools包且过程复杂。4.3 利用内置函数与工具类转换我回归到QLExpress本身的功能。它提供了一系列运算符和内置方法。例如字符串操作、集合操作等。我能否找到一个“桥接点”将文件内容以某种非java.io的方式读入我研究了目标应用可能引入的其他工具库。通过一些报错信息我发现应用使用了commons-io库。这是一个非常通用的IO工具库。我尝试了import org.apache.commons.io.FileUtils; FileUtils.readFileToString(new java.io.File(/etc/passwd), UTF-8)不出所料java.io.File触发了黑名单。即使FileUtils本身没被禁但它的参数包含了被禁的类。4.4 终极绕过结合SSRF与file://协议的其他客户端既然直接的file://协议在RestTemplate中行不通我继续寻找其他支持该协议的客户端。一个常见的库是OkHttp但目标环境不一定有。另一个思路是回到最基础的java.net.URL但我们需要绕过对“URL”这个字符串的检查。字符串混淆技巧 黑名单检查很可能发生在表达式执行之前是对原始输入字符串的匹配。那么我可以通过QLExpress的字符串运算能力在运行时“拼凑”出被禁的类名。// 思路将java.net.URL拆分成多个部分在运行时拼接 def p1 java.; def p2 net.; def p3 URL; def className p1 p2 p3; // 运行时拼接为java.net.URL // 但如何用这个字符串去加载类Class.forName被禁了。即使拼出了类名没有Class.forName或loadClass也无法实例化。我们需要一个接收字符串参数并返回对应类对象的方法。我发现了QLExpress的一个隐藏特性它支持直接使用类的全限定名进行静态方法调用在某些版本中。我尝试了// 直接使用全类名调用静态方法但需要绕过对‘URL’的字符串检查 // 尝试使用字符编码或反转 def url file:///etc/passwd; // 方法1使用字符的ASCII码拼接 (过于复杂QLExpress不支持直接char转换) // 方法2使用字符串替换 def className java. net. URL; // 简单的拼接在提交前还是完整的字符串会被检查看来在表达式层面进行字符串混淆很难绕过提交时的静态检查。必须换一个角度寻找一个默认就支持file://协议且类名不在黑名单中的现成工具类。经过大量搜索和测试我最终找到了突破口javax.script.ScriptEngineManager和javax.xml.xpath.XPathFactory。这两个类在某些场景下可以用于加载外部URI资源。但测试过程并不顺利它们对file://协议的支持和回显方式不理想。4.5 迂回策略利用报错信息读取文件这是一个非常经典的技巧。如果无法直接读取文件内容能否让应用在尝试处理文件时将文件内容以错误信息的形式泄露出来我尝试构造一个表达式让QLExpress去“调用”一个不存在的类但类名我指定为文件路径// 尝试触发ClassNotFoundException并在信息中带入文件路径不行。 def x new file:///etc/passwd(); // 语法错误QLExpress不支持。这条路走不通。QLExpress的语法错误不会包含未经验证的字符串内容。最终我结合SSRF的发现想到了一个更通用的方法很多Java应用会依赖一些XML解析库如解析外部实体XXE。虽然QLExpress本身不是XML解析器但能否在表达式里触发一个XML解析过程这需要环境中有相关的库并且能找到调用入口难度较大。实战中的妥协与成果 在真实的渗透测试中时间有限。当我通过RestTemplate实现了对内部HTTP服务的探测和访问并通过Socket实现了端口扫描后文件读取的迫切性相对降低。内网HTTP服务如管理后台、API接口本身可能泄露敏感信息。此外通过SSRF访问到的内网服务如Redis如果配置不当可能本身就是更大的突破口文件读取可以通过那些服务间接实现例如利用Redis写Webshell。因此我将文件读取的目标暂时搁置转而利用已实现的SSRF能力进行更深度的内网横向移动测试。这体现了实战中的一个重要原则优先利用已证实的、高成功率的攻击向量扩大战果而不是在一条困难的技术路径上钻牛角尖。5. 防御方案与安全开发建议通过这个案例我们可以清晰地看到黑名单防御的苍白无力。对于QLExpress或任何表达式引擎、动态脚本引擎的安全使用我总结出以下几点防御建议供开发和安全人员参考5.1 白名单是唯一可靠的选择绝对不要使用黑名单。应该转而使用白名单机制只允许执行明确安全的操作。函数/方法白名单QLExpress支持设置自定义函数。应该只将业务需要的、安全的函数暴露给表达式使用。例如只允许使用数学运算函数、字符串处理函数、特定的业务工具类方法等。类白名单可以配置QLExpress的ClassLoader使用一个自定义的、只加载了白名单类的类加载器从根本上杜绝加载危险类。示例QLExpress白名单配置思路// 自定义一个安全的类加载器 public class SecureExpressClassLoader extends ClassLoader { private SetString allowedClasses new HashSet(Arrays.asList( java.lang.String, java.lang.Integer, java.util.Map, com.yourcompany.util.SafeMathTool, // ... 仅添加业务必须的类 )); Override protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { if (!allowedClasses.contains(name)) { throw new ClassNotFoundException(Class not allowed: name); } return super.loadClass(name, resolve); } } // 在初始化QLExpress Runner时使用这个ClassLoader ExpressRunner runner new ExpressRunner(); runner.setClassLoader(new SecureExpressClassLoader());5.2 启用沙箱模式与安全控制QLExpress本身提供了一定的安全控制特性务必启用设置安全沙箱runner.setSandboxMode(true)。这会严格限制表达式的行为。设置超时时间runner.setTimeoutMillis(5000)。防止表达式陷入死循环或长时间运行。禁用不必要特性如无必要禁用import语句、禁用new操作符创建对象。5.3 输入校验与上下文隔离严格的输入校验在将用户输入传递给QLExpress之前进行严格的格式和内容校验。例如如果表达式只用于数学计算就只允许数字和运算符。上下文隔离不要将敏感对象如HttpServletRequest、数据库连接DataSource放入表达式的执行上下文IExpressContext中。只传递必要的最小数据集合。5.4 代码审查与依赖管理专项安全评审对所有使用动态脚本引擎的代码进行重点安全评审。最小化依赖移除不必要的依赖库。每多一个库就多一份潜在的攻击面正如本例中利用的RestTemplate。持续更新保持QLExpress引擎版本为最新以获取官方的安全修复。5.5 网络层与系统层防护出口流量限制在服务器或容器层面使用防火墙或安全组策略限制应用服务器发起的非必要外联请求仅允许访问已知的后端服务。这可以作为最后一道防线缓解SSRF造成的危害。最小权限原则运行Java应用的系统用户应遵循最小权限原则避免使用root或高权限账户运行从而限制文件读取的范围和系统命令执行的破坏力。6. 总结与反思这次从QLExpress黑名单绕过到实现SSRF和端口探测的实战是一次典型的“突破思维定式”的过程。CTF题目教会我们技术原理而实战要求我们将这些原理在复杂的、受限的环境中组合运用。核心的绕过思路在于理解黑名单的局限性它永远无法穷尽所有可能性尤其是在Java这样庞大的生态中。寻找替代方案当直接路径被阻断立即思考是否有功能等效但实现不同的类或方法。从URL到Socket再到RestTemplate就是这一思路的体现。利用环境特性深入了解目标应用的技术栈Spring Boot并利用其固有的、被开发者视为“安全”的依赖Spring框架的类来达成目的。攻击链思维不要执着于单一的攻击目标如一定要文件读取。SSRF本身就是一个强大的支点可以撬动内网探测、服务攻击等其他更严重的漏洞。对于防御方而言这个案例再次敲响了警钟任何形式的动态代码执行都是极高风险的操作。如果业务上必须使用那么白名单、沙箱、严格的输入输出限制是必须实现的底线安全措施。指望一个简单的关键词过滤来挡住攻击者无异于掩耳盗铃。最后这种漏洞的挖掘过程极度依赖对目标系统架构、依赖库和代码逻辑的深入理解。这需要安全研究人员不仅懂漏洞更要懂开发懂系统具备强大的“上下文构建”能力。这也是渗透测试从入门到精通的关键分水岭。