ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FreeMarker模板注入防护:重写Configuration实现安全加固

FreeMarker模板注入防护:重写Configuration实现安全加固 简介针对较老 JeecgBoot 版本无法平滑升级 FreeMarker 组件的场景这份 13KB 的轻量补丁包提供了一条直接有效的修复路径重写 freemarker 2.3.31 中的 Configuration 类并在实例化时默认注入 TemplateClassResolver.SAFER_RESOLVER从源头阻断模板注入漏洞避免因版本跨度大而被迫整体升级。资源整体为 zip 压缩包仅含 1 个 Java 源文件单一文件类型让补丁逻辑高度聚焦开发者可快速对照项目现有代码完成替换或移植适合具备 Spring Boot 与 FreeMarker 基础、正在维护旧版 JeecgBoot 的 Java 后端工程师。下载后能直接审阅类实现与关键安全配置理解在构造 Configuration 时启用安全解析器的具体写法也可作为同类漏洞手工修复的参考范例。该方案目前已有 1334 人学习/浏览对于希望以最小改动加固老系统模板安全、又不愿承担高风险升级的团队具备不错的落地参考价值。 先说一个真实到不能再真实的场景用户往一个表单里提交了一段文本后端拿到之后直接拼进 Freemarker 模板准备渲染一封通知邮件。结果没过几天线上机器被人执行了whoami再一查日志命令是隔着一层模板渲染打进来的。这类模板注入漏洞在 Freemarker 老项目里并不少见尤其当项目升级到 2.3.31 之后很多人以为官方默认配置已经足够安全结果仍然能被绕过去。这也就是我把Configuration重写一版的原因不是官方没修而是默认策略只负责兜底不负责强制。1. 先从一条绕过记录说起2.3.31 为什么还不是终点1.1 模板注入的经典利用路径Freemarker 模板注入能变成 RCE核心靠的是new这个内建函数。模板里可以写#assign 变量名某个类全限定名?new()它的语义相当于在 JVM 里调用这个类的构造器。Freemarker 自带几个非常危险的工具类其中最常被用到的是freemarker.template.utility.Execute它的构造器接收一个字符串参数然后直接当作系统命令执行。一个最典型的 payload 长这样#assign cmdwhoami #assign exfreemarker.template.utility.Execute?new() ${ex(cmd)}渲染这个模板服务器就会执行whoami。如果换成id、cat /etc/passwd或者反弹 shell 的命令效果等同于拿下一个 WebShell。另一个更通用的类是freemarker.template.utility.ObjectConstructor它可以通过反射创建任意 Java 对象比如直接构造ProcessBuilder#assign objfreemarker.template.utility.ObjectConstructor?new() ${obj(java.lang.ProcessBuilder, whoami).start()}这类利用的前提是用户输入能被拼进模板内容或者用户能直接控制上传/编辑模板文件。很多老系统不直接渲染用户输入的模板字符串而是把用户输入拼接到模板里比如String userInput request.getParameter(name); String tplStr Hello userInput !; Template t new Template(user, new StringReader(tplStr), cfg);看上去只是拼了一个变量但用户只要传#assign exfreemarker.template.utility.Execute?new()${ex(id)}就变成了一段完整的恶意模板。模板渲染发生在后端 JVM 进程中权限模型和业务代码完全一致基本等于直接 RCE。1.2 2.3.31 默认配置的改与没改FreeMarker 2.3.31 其实已经针对这个问题做过一轮修复官方把new内建的默认类解析器从原来的UNRESTRICTED_RESOLVER改成了SAFER_RESOLVER后者的作用是禁止freemarker.template.utility.*包下的危险工具类通过new创建。换句话说如果你升级到 2.3.31 之后直接用new Configuration(Configuration.VERSION_2_3_31)创建一个全新的配置前面那两个 payload 默认是跑不起来的。问题在于这个修复只是改了一个“默认值”不是加了一层“强制约束”。实际业务代码里常见这么几种情况会让官方修复形同虚设老项目升级时为了保证旧模板兼容显式调用了setNewBuiltinClassResolver(TemplateClassResolver.UNRESTRICTED_RESOLVER)等于手动把安全开关关掉。项目里用的不是默认的ObjectWrapper而是自己封装的BeansWrapper在包装策略里把反射能力又放开了。框架的 starter 或者公共依赖内部创建了Configuration业务代码根本碰不到那个实例安全配置加不上。有些渲染入口绕过了Configuration.getTemplate直接new Template(name, reader, cfg)渲染字符串模板模板加载器一层完全没经过。我把 2.3.31 默认配置和薄弱点整理了一下配置面2.3.31 默认情况容易被绕过/忽略的点new内建类解析器SAFER_RESOLVER业务代码显式改回UNRESTRICTED_RESOLVER即失效对象包装器BeansWrapper旧策略自定义 wrapper 可能暴露字段反射能力模板异常处理偏调试风格异常页面泄露堆栈和模板片段模板加载器文件/classpath 策略字符串模板不经过加载器校验标题里说的“重写 Configuration”本质上就是解决“默认值不构成强约束”这个点把安全策略从“靠配置项”提升到“靠代码结构”。2. 修复方案选型为什么把 Configuration 重写一版2.1 可以选的三条路接到修复单之后我先列了一下可选方案大概有三条路。第一条路是全局搜代码把所有创建Configuration的地方都找出来逐个补齐安全配置。比如搜new Configuration、setNewBuiltinClassResolver、setObjectWrapper这些调用点。这条路的问题在于散点太多尤其是稍微大一点的项目多个模块各自初始化配置漏一个就是漏洞保留位。而且底层依赖里的初始化代码你根本搜不到最终还是会漏。第二条路是借助 Spring 或 Spring Boot 的FreeMarkerConfigurer统一注入一个配置好的ConfigurationBean。这条路比第一条好一些但只对装配了 Spring 的环境有效。一旦有独立任务、离线渲染、或者其他没走 Spring 容器的入口还是要重新写一遍安全配置。第三条路就是标题里的做法继承Configuration写一个SafeConfiguration在构造函数里把关键安全配置写死同时重写模板入口方法做二次校验业务侧统一使用这个子类。我最后选了这条路理由是它把安全基线固化在了类结构里任何模块只要用它创建配置就自带安全策略不再依赖某个开发人员有没有记得加配置。2.2 重写 Configuration 真正解决的问题表面上看重写Configuration是为了修模板注入漏洞但更深一层它是在给模板渲染入口统一建一个“安检闸口”。我实际做完之后发现它能解决这几个问题强制new内建使用受限解析器让恶意模板没法加载任意 Java 类。强制对象包装器不暴露内部字段缩小?api这类信息探测的攻击面。统一异常处理策略线上不回显模板堆栈避免攻击者通过报错信息摸清服务端结构。提供带校验的getTemplate入口模板来源可控路径穿越这类问题也能一起挡住。举个例子我手头维护的一个系统里原本有 6 处new Configuration(...)散落在会员通知、订单导出、后台报表等多个模块。修复单发下去有人改了这个模块漏了那个模块。重写成SafeConfiguration之后所有模块统一改成用这个子类新增代码也默认继承安全基线后续上线新功能不需要再重复“提醒自己加安全配置”这件事。3. 落地实现安全 Configuration 的完整代码3.1 构造函数里强制配置项SafeConfiguration的核心逻辑都放在构造函数里。这里要注意继承Configuration时必须显式传入Configuration.VERSION_2_3_31这个版本号会决定incompatible_improvements的行为直接影响某些内建函数的默认策略。如果图省事用了无参构造很多新版本的安全行为未必会全量生效。我最终的实现大概是这样的import freemarker.core.TemplateClassResolver; import freemarker.template.Configuration; import freemarker.template.DefaultObjectWrapperBuilder; import freemarker.template.TemplateExceptionHandler; public class SafeConfiguration extends Configuration { public SafeConfiguration() { super(Configuration.VERSION_2_3_31); // 1. 禁止模板中通过 ?new 加载任意 Java 类 setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER); // 2. 对象包装器不暴露内部字段 DefaultObjectWrapperBuilder wrapperBuilder new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_31); wrapperBuilder.setExposeFields(false); wrapperBuilder.setUseAdaptersForContainers(true); setObjectWrapper(wrapperBuilder.build()); // 3. 异常处理线上不回显模板片段与堆栈 setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); setLogTemplateExceptions(false); setWrapUncheckedExceptions(true); } }三个配置各有各的作用。第一处ALLOWS_NOTHING_RESOLVER比SAFER_RESOLVER更严格它基本禁止了模板通过new创建任意 Java 类。我调研过现有业务模板绝大多数模板根本用不到new所以直接全禁是最省心的选择。第二处setExposeFields(false)是配合?api内建使用的它避免模板通过?api访问对象的私有字段。第三处异常处理是为了防止报错页泄露内部信息这个点后面会细说。3.2 模板加载入口的二次收口构造函数里的配置只能约束“解析器行为”拦不住“模板来源不可控”的问题。比如模板名可以带路径穿越符号或者模板来自数据库、配置中心等不受文件系统控制的来源。所以我还重写了getTemplate入口在真正加载模板之前做一层来源校验。import java.io.IOException; import java.nio.file.Path; import java.util.HashSet; import java.util.Locale; import java.util.Set; public class SafeConfiguration extends Configuration { private final SetPath allowedTemplateRoots new HashSet(); public SafeConfiguration() { // 省略 3.1 中的配置 } public void addAllowedTemplateRoot(Path root) { this.allowedTemplateRoots.add(root.normalize()); } Override public Template getTemplate(String name, Locale locale, String encoding, boolean parseAsFTL) throws IOException { checkTemplateSource(name); return super.getTemplate(name, locale, encoding, parseAsFTL); } private void checkTemplateSource(String name) throws TemplateNotFoundException { if (name null || name.isBlank()) { throw new TemplateNotFoundException(, null, null); } Path path Path.of(name).normalize(); boolean allowed allowedTemplateRoots.stream() .anyMatch(root - path.startsWith(root)); if (!allowed) { throw new TemplateNotFoundException(name, null, null); } } }这里有个容易忽略的细节Path.of(name).normalize()一定要调用否则a/../../b这类包含..的路径可能利用前缀匹配绕过校验。先normalize再判断startsWith路径穿越就会被拦在入口。另外必须说明重写getTemplate拦不住另一种常见入口直接new Template(inline, new StringReader(userInput), configuration)渲染字符串模板。这种场景下模板内容根本没有经过模板名加载逻辑校验要放在模板源码进入渲染引擎之前。我的做法是在SafeConfiguration里提供一个静态校验方法创建字符串模板前先跑一遍private static final ListString FORBIDDEN_EXPRESSIONS List.of( ?new, freemarker.template.utility., ?api ); public static void assertSafeTemplateSource(String source) { if (source null) { return; } for (String forbidden : FORBIDDEN_EXPRESSIONS) { if (source.contains(forbidden)) { throw new IllegalArgumentException(模板内容包含被禁止的表达式: forbidden); } } }静态扫描属于兜底方案不能只靠它它拦不住加密混淆或者复杂的表达式拼接但作为第一道闸门很实用至少能把最常见的那几个危险写法挡在门外。3.3 配置后与业务代码的兼容边界安全配置做好之后最现实的问题就是业务模板会不会报错我实际集成时遇到几个点提前列出来供参考。第一个是存量模板里确实有依赖?new的。比如有个报表模板里写#assign now...?new(java.util.Date)这种直接会抛异常。我的处理是先把这些模板清单拉出来尽量改造为后端传参让模板只负责展示确实没法改的再针对单个模板注册一个受限的类解析器允许白名单内的几个类而不是开放所有类。第二个是BeansWrapper的替换。有些老项目拿BeansWrapper.getDefaultInstance()当作静态共享实例这个实例的安全配置很可能还是旧的需要统一替换成DefaultObjectWrapperBuilder构建出来的新实例否则一部分数据模型拿到的包装行为还是旧的。第三个是模板缓存。Configuration默认开启模板缓存如果线上此前已经加载过恶意模板或者有被污染过的模板缓存修复代码上线后旧模板可能还会被继续用。上线前要清一次缓存或者在发布流程里加一步清理动作。第四个是异常处理策略变化。原来依赖HTML_DEBUG_HANDLER的开发调试体验会消失线上报错不再展示完整堆栈。开发环境可以保留调试日志生产环境使用RETHROW_HANDLER配合统一异常响应具体堆栈落到日志系统里排查。4. 回归验证用恶意模板目录做一轮攻击面复查4.1 准备一组典型 payload代码写完了不能只靠“理论上应该安全”来交差。我把恶意模板整理成一个固定目录作为安全回归用例每次改造完都跑一遍。目录结构大致如下src/test/resources/malicious/ ├── exploit_new_execute.ftl ├── exploit_object_constructor.ftl ├── probe_api.ftl └── normal.ftl其中exploit_new_execute.ftl是最常见的命令执行 payload#assign cmdwhoami #assign exfreemarker.template.utility.Execute?new() ${ex(cmd)}exploit_object_constructor.ftl是反射创建对象的变种#assign objfreemarker.template.utility.ObjectConstructor?new() ${obj(java.lang.ProcessBuilder, whoami).start()}probe_api.ftl用来探测对象包装器是否暴露了不该暴露的信息${.data_model?api}normal.ftl是一个正常模板用来确保安全策略没有影响正常渲染Hello, ${name!world}测试用例就很简单了遍历malicious目录恶意模板全部断言抛异常正常模板断言能正常输出。4.2 修复前后表现对照我整理了加固前后的表现差异方便安全评审时直接看场景修复前默认/放开策略修复后SafeConfigurationExecute命令执行直接执行系统命令抛TemplateException命令不执行ObjectConstructor反射能创建ProcessBuilder抛异常构造被拦截?api探测返回对象内部结构返回受限包装结果字段不可见渲染异常页面展示模板源码和完整堆栈统一错误响应堆栈仅落日志实际测试时我还特意加了一个命令回显验证在修复前的环境中模板渲染结果里会直接看到whoami的输出修复后测试直接中断日志记录到的是模板解析异常而不是命令执行结果。这一步很重要因为有些注入 payload 即使没执行成功也会在异常信息里把服务端路径、类名等信息带出来异常处理策略不到位就等于白修。4.3 线上回归时容易漏的几类配置回归测试不能只在单元测试里跑线上环境还有几个坑容易被忽略。第一类是框架集成问题。比如 Spring Boot 里的FreeMarkerConfigurer可能自行创建了Configuration你注入的SafeConfiguration如果没有被真正用到线上生效的还是原来的配置。集成时一定要确认FreeMarkerConfigurer使用的是哪个Configuration实例别改了代码但实例没挂上去。第二类是模板加载器覆盖问题。StringTemplateLoader、ClassTemplateLoader这些不同的加载器触发的模板入口方法不完全相同重写了getTemplate也不能保证所有加载器都走了同一套校验。我建议在SafeConfiguration的构造器里统一指定加载器或者在测试里把几种加载器都覆盖到。第三类是其他危险内建函数。?eval能执行动态 FTL 表达式单看它本身不会直接 RCE但要是模型数据里带着危险对象?eval可能成为攻击链的一环。安全回归用例里最好把?eval也纳入静态扫描的黑名单。5. 从这个项目里总结的几点体会5.1 “重写”是为了让安全默认值不被业务覆盖这个项目做完之后我最大的感受是安全加固如果没有一个强制的“默认安全”机制很容易被人为或框架层面的配置覆盖掉。重写Configuration的实质不是写了一个更聪明的解析器而是把安全策略编码进了类的构造逻辑里。只要项目里统一用SafeConfiguration不管是谁新写代码、新接模块默认就是安全的。我之前遇到过一种情况第一次修复完成两个月后有人为了给某个模板加自定义函数在新模块里又写了一个裸的new Configuration(...)安全扫描立刻报警。后来把SafeConfiguration放进公共依赖模块并且把“新代码禁止直接 new Configuration”写进代码评审规则这个问题才算真正按住。5.2 不要只盯 new还要看模板异常和缓存很多人接到模板注入的修复单第一反应是处理new内建改完就以为结束了。但从我的排查经验看new是 RCE 的核心异常处理和模板缓存同样是要命的口子。默认的HTML_DEBUG_HANDLER会把模板源码片段、类加载器信息、堆栈路径一五一十地渲染到响应里。就算注入没有真正执行攻击者也能靠这些报错信息把黑盒变成白盒。所以我把异常策略也放进SafeConfiguration的构造器里保证线上永远走统一错误响应。模板缓存的问题则在于“旧模板残留”。如果你此前已经加载过一个恶意模板修完代码后没有清缓存下一次请求可能命中的还是旧模板修复在线上等于没生效。上线前清缓存这个动作我建议写进发布 checklist而不是靠人脑记住。5.3 加固后的功能回归要点最后分享一个比较笨但很管用的方法把线上所有存量模板收集起来跑一个“全量模板编译”脚本让SafeConfiguration加载一遍所有模板。这样能提前暴露出哪些模板悄悄用了被禁的危险内建不用等用户访问到那个页面才发现报错。对依赖?new的模板我自己的原则是能在后端用模型传参解决的绝不在模板里创建对象确实需要动态类的单个模板注册一个受限的类解析器只允许指定白名单包而不是整体放开。再往后每次升级 FreeMarker 版本都要重新跑一遍第 4 节的安全回归用例防止新版本把默认行为又改回去。如果后面再接到这类模板注入的整改单我的建议就是别急着搜各种现成的配置片段先按这个思路把攻击面理一遍模板内容能不能被用户控制、模板来源能不能被限定、异常会不会泄露信息、缓存有没有残留风险。把这四个问题都处理干净SafeConfiguration才能真正成为一道长期有效的闸口。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进