做应用安全这么多年,我越来越觉得一个道理:CTF里学到的攻击思路,放到真实业务里往往更凶险。就拿标题这个场景来说,一个带黑名单校验的QLExpress表达式引擎,明明把Runtime、ProcessBuilder、Class、ClassLoader这些高危词全都禁了,结果还是被人从一条“看起来无害”的表达式里,硬生生打出了SSRF和任意文件读取。这篇内容不是考古某个具体题目的答案,而是把“绕过黑名单”背后的通用思路拆开讲清楚,再把从CTF过渡到实战时需要注意的差异、利用链细节和防御策略一并整理出来。适合搞安全研发、代码审计,以及所有需要自己维护表达式引擎边界的人参考。
1. 从CTF题联想到真实场景
1.1 业务里为什么处处都是QLExpress
先别急着看漏洞,我们得先理解表达式引擎在业务里到底承担什么角色。
QLExpress是很多互联网公司业务系统里常用的Java表达式引擎,特点是小巧、执行效率高,而且支持接近Java语法的表达式脚本。它最常见的应用场景是动态配置化的业务规则:比如风控系统要调整“单日下单次数限制”、营销系统要下发“用户优惠券发放规则”、交易系统要配置“手续费打折条件”。这些规则如果写死在代码里,每次调整都要发版、走流程,业务等不起;如果做成配置项,后端又不可能为每种规则类型都写一个专用配置模型。于是表达式引擎就成了“配置即代码”的中间层——运维或运营在后台页面改一段表达式,系统下一次执行时自动读最新规则。
这个设计本身没有问题,但问题随之而来:表达式引擎拥有执行Java代码的能力。哪怕它只开放部分语法,在Java生态里“能执行代码”就意味着“能做很多事情”。所以几乎所有接入QLExpress的系统,都会在引擎外层加一道“安全管理”,最常见的就是黑名单机制。
1.2 黑名单机制是怎么设计出来的
黑名单的设计思路非常直白:维护一个字符串列表,把危险的类名、方法名、包名放进去,比如经典的Runtime、ProcessBuilder、Class、ClassLoader、Method、exec、invoke、forName,然后在表达式执行前做一遍扫描,或者对解析后的语法树节点做校验,发现命中就拒绝执行。
从我审计过的真实代码来看,实现方式主要分两种:
- 词法扫描:把整个表达式当成字符串,用正则或
contains判断是否包含黑名单关键词。这种方式实现最简单,也因此最容易被绕过,比如"java.net."+"URL"这种字符串拼接就能让它“看走眼”。 - 语法树校验:先把表达式解析成AST节点,再逐个节点检查类型、方法名和参数类型。这种方式比词法扫描强不少,但它检查的仍然是“节点是否命中黑名单”,本质还是枚举拦截。
这里要强调一个核心问题:黑名单的哲学就是“列出来你不想要的东西”。可攻击者真正在找的,恰恰是你没列出来的那部分。黑名单永远存在两个漏洞:一是列表不完整,二是检查维度不完整。比如你禁了Class.forName,但类加载器上还有loadClass这个方法;你禁了java.io.File字符串,但攻击者可以用字节数组动态拼出类名;你禁了getMethod和invoke这两个方法名,但攻击者可以先加载java.lang.reflect.Method类,再用类加载器把方法名当字符串传进去。
1.3 CTF和实战的关键差异
CTF里给出一道QLExpress绕过题,通常环境干净、目标明确,比如“读本机某个文件”或者“访问内网某端口”。你只需要找到一个能跑的payload,交上去就完事。但真实业务场景完全是另一回事:
- 回显机制不一样。CTF题经常直接把表达式执行结果返回到页面,实战里很多系统会吞掉异常、过滤输出,甚至表达式结果根本不展示,这时候你要考虑“盲打”思路,通过执行时间、异常差异、外带数据等方式确认漏洞。
- 目标更复杂。实战中文件读取不是最终目的,真正的目标可能是读取云服务器的元数据、探测内网端口、读取配置文件里的密钥,甚至由文件读取继续跨到RCE,形成完整攻击链。
- 黑名单是动态的。CTF题的黑名单是固定的,实战里安全团队可能已经做过很多轮加固,你需要在“绕过结果到底触发在哪一层校验”上做更细致的判断。
这也是为什么我说“只背payload没有意义”。如果只知道某个特定表达式能绕过,换一个版本、换一个黑名单维度就失效;但如果你理解了“拿到类加载器之后,黑名单的类名拦截就形同虚设”这个本质,换个业务场景依然能用同一套思路打穿。
2. 黑名单为什么拦不住QLExpress
2.1 拦截面天生有限
我们在给表达式引擎做安全边界时,必须意识到一个现实:QLExpress不是只执行数学运算的脚本,它保留了大量Java对象方法调用能力。
一个常规的QLExpress表达式,可以写变量赋值、对象创建、方法链式调用、三元表达式、数组、Map/List操作。这在业务上很方便,但也意味着攻击者能“借用”这些白名单能力做组合攻击。举个例子,业务规则里很常见的一个操作是访问String对象的方法,比如"abc".length()。既然能调用String的方法,那能不能调用"".getClass()?这其实就是在问:引擎允不允许你调用所有Java对象继承自Object的方法。
很多实现者在配置黑名单时,只盯着Runtime、exec这类显眼目标,却忽略了getClass()、getClassLoader()这种“元能力”。攻击者一旦能执行getClass(),就能拿到Class对象;有了Class对象,就能进一步拿ClassLoader;有了ClassLoader,理论上就能加载JVM里任意类。黑名单的枚举再长,也枚举不到“组合后的利用链”,因为它不是一个“能力边界”,只是一个“已知危险列表”。
2.2 关键突破口:对象方法调用+类加载器
这类绕过的本质可以概括成一句话:只要表达式引擎允许调用对象方法,且运行环境允许拿到类加载器,攻击者就可以逐步获取任意类并触发其能力。
我们拿一个简单链路举例。假设黑名单禁掉了字符串Class和ClassLoader关键词,也禁掉了forName和exec。但一个普通表达式仍然可以这样操作:
"".getClass()这一步不包含任何被黑名单拦截的字符串。表达式执行后得到一个Class<String>对象。接着:
"".getClass().getClassLoader()这一步执行后得到一个ClassLoader实例。关键是,很多黑名单实现者完全没有意识到“字符串里没出现ClassLoader,但对象类型已经是ClassLoader了”。拿到这个实例之后,你让它加载什么类,它就加载什么类:
"".getClass().getClassLoader().loadClass("java.net.URL")到这里,黑名单里写没写URL、URLConnection都无所谓了——攻击者没有直接引用这些高危词的字符串,而是通过“类加载器动态加载”这个路径拿到了类对象。这条链路的通用性很强,它不依赖于某个特定的表达式引擎漏洞,而是Java本身的对象模型决定的。
如果黑名单连loadClass这个方法名都禁了,也还有退路。攻击者可以加载java.lang.reflect.Method,然后用getMethod动态传入方法名。方法名本身可以用字节数组构造,比如把loadClass的每个字符转换成ASCII码。黑名单扫描的是“表达式中是否出现某个字符串”,而攻击者在表达式里写的是一堆数字和byte[],扫描出来完全干净。
2.3 为什么这个思路能迁移到其他引擎
不只是QLExpress,只要表达式引擎底层跑在JVM上,且保留“调用任意Java对象方法”的能力,都存在同类绕过可能。SpEL、OGNL、MVEL、JEXL等表达式引擎,历史上都出现过类似的“getClass()->getClassLoader()-> 加载任意类”绕过链条。
换句话说,黑名单式防御存在结构性缺陷:它把安全寄托在“攻击者不知道有遗漏”上,而不是“攻击者无论如何都突破不了能力边界”上。一个真正可靠的表达式引擎安全方案,必须明确回答一个问题:一段表达式到底能调用哪些类、哪些方法、哪些资源?回答不了这个问题,黑名单加得再多,也只能延缓攻击节奏,不能阻断攻击路径。
3. 把绕过思路落到具体利用链
3.1 搭建一个模拟验证环境
为了把链路讲清楚,我在本地搭过一个模拟环境,用来验证黑名单绕过和文件读取/SSRF数据回显。环境配置大概是这样:
- 模拟业务系统使用某个版本的QLExpress引擎,版本本身不是重点,思路可复用。
- 黑名单设置:禁止表达式中出现
Runtime、ProcessBuilder、Process、ClassLoader、java.lang.reflect、http等关键词,禁止调用exec、invoke、forName这几个方法名,同时禁止new关键字。 - 模拟目标:一个常见的业务接口,用户可控的“规则表达式”会被后端当作QLExpress脚本执行,并且表达式执行结果会以JSON形式返回给调用方。
- 验证说明:所有测试都在本地模拟项目X里进行,没有影响任何线上系统。模拟环境不一定与线上真实版本完全一致,但利用链的通用性足以用来做防御分析。
搭建这个环境的目的,不是为了“复现一次攻击表演”,而是想让自己搞清楚:当黑名单删掉显眼的危险词之后,攻击者手里还剩下什么牌。验证结果表明,剩下的牌远比我预想的多。
3.2 绕过第一步:建立“任意类加载”能力
前面已经提到底层思路,这里给一个更接近实际脚本操作的步骤拆解。
第一步,拿到当前执行上下文里的类加载器。常规方式是:
loader = "".getClass().getClassLoader();如果执行getClassLoader()返回null,说明当前类是由引导类加载器加载的,这时可以尝试通过上下文类加载器绕一下:
loader = Thread.currentThread().getContextClassLoader();第二步,通过这个类加载器加载后续需要使用的类。比如要验证文件读取能力,可以加载java.io.File、java.io.FileInputStream、java.util.Scanner;要验证SSRF能力,就加载java.net.URL、java.net.HttpURLConnection。由于黑名单可能拦截表达式中直接出现的可疑字符串,类名可以拆成字节数组或字符串拼接形式。
这里要提一个我踩过的坑:表达式引擎校检查的是“脚本文本”,不一定检查脚本运行后动态产生的类名。也就是说,你可以在表达式里写:
x = new String(new byte[]{106, 97, 118, 97, 46, 105, 111, 46, 70, 105, 108, 101});这段代码本身不包含File这个连续字符串。当黑名单是词法扫描时,直接绕过;当黑名单是AST校验时,也要看它有没有对“字节数组转字符串再作为类名加载”这个行为做语义追踪。很多实现压根没做到这一步。
第三步,实例化目标对象。如果目标类有无参构造,newInstance()就能生效;如果要传参,比如File类需要一个路径字符串,就需要先拿到Constructor对象,再调用newInstance(参数)。这一步在表达式中会相对长一些,但在表达式引擎的多语句场景下完全可行。QLExpress支持分号分隔多个表达式,中间变量可以留存,所以一个完整利用链可以拆成多个步骤,而不是一锤子买卖。
3.3 文件读取利用链的完整构造
文件读取的核心目标是把目标文件内容变成字符串,最终作为表达式返回值回显出来。以读取系统上一个明文配置文件为例,操作链路是这样的:
1. 拿到ClassLoader实例 2. 通过ClassLoader加载java.io.File类 3. 拿到File类的Constructor,参数类型是java.lang.String 4. 传入文件路径,构造File对象 5. 加载java.io.FileInputStream类,拿到Constructor,参数类型是java.io.File 6. 用File对象构造FileInputStream 7. 加载java.util.Scanner类,拿到Constructor,参数类型是java.io.InputStream 8. 用FileInputStream构造Scanner对象 9. 调用scanner.useDelimiter("\\A").next(),将整个流内容读取为字符串最后这个字符串会作为表达式结果返回。整个过程中,脚本里几乎不会出现看起来危险的类名,因为类名全是通过类加载器动态加载进来的;路径字符串也可以拆分构造,比如"/e" + "tc/passwd"这种形式,以对抗关键词扫描。
实际调试时,我更推荐用java.nio.file.Files.readAllBytes()配合Paths.get()来简化读取流程,因为Files.readAllBytes一步就能把整个文件读成byte[],再转成字符串。缺点是类路径和调用层次也有点多,不如Scanner链路直观。两种方式在模拟环境中都验证过,区别只是表现手法的差异,底层原理一样。
这里还要补充一种更隐蔽的文件读取方式:通过类加载器加载classpath资源。loader.getResourceAsStream("classpath下的资源名")可以直接读取jar包或配置目录里的文件,不需要构造File对象。如果目标系统的敏感数据在classpath中,比如数据库配置文件、密钥文件,这个方式往往更省事,而且更容易绕过“文件路径”维度的黑名单。
3.4 SSRF利用链的核心差异
SSRF的思路与文件读取几乎同构,只是把“读文件”换成“发请求”。核心目标有两个:一是探测内网端口或访问本机服务,二是读取请求响应内容并回显。
经典操作是用java.net.URL发起连接:
1. 通过ClassLoader加载java.net.URL类 2. 用构造方法传入目标URL字符串 3. 调用openConnection()得到URLConnection对象 4. 调用getInputStream()拿到响应流 5. 用Scanner把响应内容读成字符串如果黑名单禁止URL字符串或禁止openConnection方法名,同样可以用类加载器+方法名拼接解决。还有一种替代方案是加载java.net.Socket类,直接指定目标IP和端口,连接成功与否会产生不同的异常信息,这些异常信息如果被接口原样返回,就成了天然的端口扫描信号。
在我验证的模拟环境里,SSRF回显比文件读取更依赖“网络可达性”。本地搭建的模拟服务可以立刻看到效果,但真实业务系统往往有出网策略限制。所以在实战中,攻击者通常会先做一次“探测请求”,比如请求一个外部可控的服务,观察能否收到请求记录,以此判断出网是否被限制。如果完全不出网,内网目标仍然是可达的,SSRF的价值还在,只是回显方式会变成“基于连接结果的布尔盲注”。
3.5 回显不存在的场景怎么办
CTF题默认表达式结果会回显,但真实业务里回显经常被弱化,甚至完全不展示。我在实际测试过程中遇到不少这种情况,需要把思路从“直接读取结果”转成“通过副通道验证”。
至少有两种可行的替代思路:
- 时间盲注:表达式可以执行耗时操作,比如让当前线程
sleep一段时间。如果表达式里可控逻辑走到指定分支时延迟明显,就可以把“目标文件是否存在”“内网端口是否开放”编码成时间差异。QLExpress脚本可以通过加载java.lang.Thread类并调用sleep实现,黑名单即使禁了Thread字符串,也还是绕不开类加载器。 - 异常信息外带:很多后端框架会把表达式执行异常堆栈返回给请求方,虽然不回显正常结果,但异常中包含类名、行号、路径信息。攻击者可以让表达式在“条件成立时抛出一个包含特定信息的异常”,用异常内容替代正常回显。
这两种方式都不依赖表达式结果展示,防御者要留意的不仅是“正常返回值”,还有“异常分支的返回值”。
3.6 实际调试中踩过的几个坑
第一次尝试完整读出文件时,我遇到一个很典型的问题:表达式报错提示找不到Scanner类。排查后发现不是思路不对,而是当前ClassLoader拿错了——"".getClass().getClassLoader()拿到的是系统类加载器,而目标业务代码的类可能是由另一个Web应用类加载器加载的,接口里执行表达式时上下文不同。后来改成Thread.currentThread().getContextClassLoader()就正常了。对攻击者来说,多试几种获取类加载器的方式是基本功;对防御者来说,这个细节提醒我们“上下文类加载器”也可能被用作传送门。
另一个坑是JDK版本差异。高版本JDK里Class.newInstance()被标记废弃,有些环境甚至直接禁止反射调用它,必须改用getDeclaredConstructor再newInstance。所以利用链要写成“通过反射拿构造器”,而不能假设newInstance()永远可用。顺带一提,getDeclaredConstructor在表达式的黑名单词汇表里也很少出现,这恰恰说明黑名单的粒度很难对齐实际利用面。
4. 从攻击视角反过来设计防御
4.1 把黑名单彻底换掉:优先做白名单校验
我在审过几个被绕过的系统后,基本形成一个判断:表达式引擎的安全边界只能建立白名单,不能依赖黑名单。黑名单是“已知危险的枚举”,白名单是“允许能力的枚举”,两者的维护成本和安全性完全不在一个量级。
白名单可以分两层来做:
- 语法层白名单:解析表达式为AST,只允许脚本中出现“赋值、算术、逻辑、条件表达式、Map/List访问、调用已注册的业务函数”这几类节点。凡是出现对象方法调用、静态方法调用、类创建等语法节点的表达式直接拒绝。业务需要什么能力,就注册成自定义函数,比如
calcCoupon(userId)、checkRiskLevel(orderId),脚本只能调用这些注册函数,不能自己拼方法链。 - 类加载层白名单:如果业务场景确实需要脚本操作一些对象,比如日期格式化、字符串处理,就提供一个专用工具类,只暴露必要的public方法,并对类加载器做隔离,确保脚本拿不到应用类加载器。
在QLExpress里,官方本身支持注册自定义Operator和函数,也支持在执行前对语法树做遍历。健康的做法是把这些机制用起来,而不是只套一层字符串黑名单。
4.2 把执行环境当成不可信环境来隔离
即便做了白名单,也不能假设表达式引擎内部就绝对安全,因为它跑在应用进程内,一旦绕过校验,和代码执行没有本质区别。因此运行环境隔离是第二道防线。
比较常见且有效的隔离手段包括:
- 独立脚本执行进程:把表达式提交到一个单独的服务进程执行,该进程运行在独立容器里,通过RPC或HTTP供给业务系统调用。即使脚本本身被绕过,攻击者拿到的也是一个受限的shell环境,而非业务主机的完整权限。
- 自定义类加载器:业务系统单独建一个ClassLoader,只加载脚本允许访问的类,同时做字节码层面的包访问控制。脚本里通过ClassLoader加载
java.net.URL时,如果自定义类加载器本身就没授权加载这类类,调用会直接失败。 - 系统级资源限制:用操作系统账号权限、Seccomp(如果是容器化部署)、文件系统只读挂载、网络策略等方式限制脚本执行进程的权限。
很多团队忽略了“执行环境隔离”这条线,觉得只要表达式校验严格就够了。但从真实攻防看,绕过校验的漏洞几乎不可能根绝,攻击链最终落地靠的还是“执行环境的权限过大”。把执行环境权限缩到最小,即使某天校验被绕过,攻击者也会发现自己什么都干不了。
4.3 主动检测和兜底手段
安全建设不能只靠事前防御,检测和兜底同样重要。对于表达式引擎这种情况,有几个务实的方向:
- 表达式安全审计:对提交到引擎的表达式做全量日志记录,包括来源、执行结果、耗时、异常堆栈。出问题时,日志能直接还原攻击者的payload。
- 异常行为监控:检测执行上下文中是否出现类加载器调用、动态类名、URL连接、文件路径构造等异常行为。这类行为在正常业务表达式里极少出现,一旦出现就值得告警。
- 安全更新跟踪:表达式引擎本身的安全补丁要关注,但更关键的是业务侧对引擎的封装方式。很多漏洞不是引擎框架的了,而是接入方自己的黑名单和校验逻辑不完整。定期做一次安全评审,把“引擎能否被用来执行任意类加载”当成一个独立的检查项。
4.4 像攻击者一样去评审“表达式权限”
我印象最深的一次安全评审,是让研发同学列出“表达式引擎到底能访问什么”。结果大家列出来的仍然是黑名单里那些词条——Runtime、exec、File。但当我在测试环境里用一条表达式拿到Thread.currentThread().getContextClassLoader()并成功加载一个自定义类时,整个团队才意识到,真正的问题不是“运行时能干什么”,而是“表达式允许脚本触碰哪些Java对象”。
所以我在做评审时,会给团队一个很简单的练习:不看任何payload,只思考“如果表达式可以调用任意实例方法,攻击者距离读取文件还有几步”。答案是三步:拿到类加载器、加载文件类、读取内容。如果表达式还能创建对象和构造字符串,距离SSRF也只有三步。这个练习能帮助团队把注意力从“黑名单漏了谁”转移到“能力边界是不是太宽”上。
5. 常见问题排查与实操心得
这里整理一些我在验证和防御过程中反复遇到的问题,做成一个速查表,方便直接对照检查。
| 现象 | 可能原因 | 排查/处理方向 |
|---|---|---|
表达式里"".getClass()执行正常,但getClassLoader()返回null | 当前Class对象由引导类加载器加载,常见于JDK系统类 | 改用Thread.currentThread().getContextClassLoader()获取上下文类加载器 |
通过类加载器加载类时报ClassNotFoundException | 类名构造错误,或目标类不在当前类加载器的可见范围内 | 检查类名是否拼接正确;换用业务代码自定义类的类加载器再试 |
| 黑名单扫描到拼接后的字符串仍然拦截 | 说明对方做了AST语义还原,不只依赖词法匹配 | 单纯字符串拆分已失效,需要寻找其他纯语义绕过路线 |
反射调用newInstance被拒 | JDK版本较高或环境禁用了废弃方法 | 改为getDeclaredConstructor().newInstance(),传入构造参数 |
| 表达式读取文件成功但接口不回显结果 | 业务侧对返回值做了过滤 | 改用异常外带、执行时间盲注等方式确认 |
| SSRF请求外部服务一直超时 | 出网策略限制,外网不可达 | 转向探测内网目标或本机回环服务 |
| 加了更多类名到黑名单后仍被绕过 | 黑名单粒度太粗,只拦了类名没拦方法调用链 | 用AST白名单替代黑名单,限制方法调用范围 |
除了对照表,还有几条实操心得值得单拎出来说说。
第一,构造链时优先验证最小步骤,不要一次写完整个payload。先把getClassLoader()能执行验证出来,再单独验证loadClass()返回Class对象,再验证构造器和实例化,最后才拼读取流程。每一步分开测试,报错时能立刻定位问题。CTF里时间紧迫可能会一把梭,实战里环境复杂得多,分步验证反而更快。
第二,多准备几种“同功能不同形态”的类。比如读取文件可以用FileInputStream,也可以用Files.readAllBytes,还可以用RandomAccessFile;发送请求可以用URL.openConnection,也可以用HttpURLConnection或Socket。黑名单防住了其中一种,换一种形态又能跑通。防御端也要理解这个特点:只封某几个类名没有意义,必须封掉“类加载”这个动作本身。
第三,防御和攻击研究是同一件事。我建议每个负责表达式引擎安全的同学,都自己设计一个“绕过挑战”实验:给定一个带黑名单的QLExpress环境,目标是把本机一个文件路径读取出来。你亲自做一次绕过,比看十篇安全公告更能理解黑名单的局限。做完之后再去设计防御方案,你自然会把重心从“更新黑名单”转向“限制能力边界”。
第四,关注上下文类加载器这个隐性入口。很多攻击链都以"".getClass().getClassLoader()为开头,但真实Web环境里更常见的是Thread.currentThread().getContextClassLoader()。防御者的AST白名单校验如果只盯着getClassLoader方法名,很可能漏掉getContextClassLoader;反过来,攻击者也可以借助这个差异测试出对方的校验粒度。类似这种“名字不一样但功能等价”的方法,在Java标准库里还有不少,比如forName和loadClass、invoke和invokeExact,都是黑名单难以穷尽的点。
最后说点个人体会
把这条从CTF题演化而来的QLExpress绕过链完整走了一遍之后,我最深的感受不是“黑名单防御有多弱”,而是“技术分析如果不结合实际防御视角,很容易变成纯粹的炫技”。如果我只给你一段能跑通的表达式,却没有讲清“为什么它绕得过黑名单”“为什么类加载器是核心中枢”“防御端该从哪个环节阻断”,那这篇内容就没有真正落地。CTF题目里那种点到为止的利用,放到真实系统里,攻击者考虑的是回显、出网、类加载器差异、版本兼容、黑名单演进这些工程化问题;防御者同样要考虑,我该在AST校验、类加载隔离、运行时检测和最小权限这几个维度上做到什么程度。希望这篇复盘能帮你在下一次遇到表达式引擎时,少走[我踩过的那几步弯路——也会让你更认同一个结论:让一个通用脚本引擎保持安全,最好的方式从来不是“不断加黑名单”,而是从一开始就让脚本只能做你允许它做的事。