Freemarker 2.3.31 升级踩坑:模板注入漏洞修复与 Configuration 安全配置

发布时间:2026/10/23 18:29:26
Freemarker 2.3.31 升级踩坑:模板注入漏洞修复与 Configuration 安全配置

简介:这份资源面向仍在使用老版本 jeecgboot、无法平滑升级的 Java 开发者,针对 Freemarker 模板注入漏洞提供一种低成本修复思路:直接重写 freemarker 的 Configuration 类,在实例化方法中默认加入 SAFER_RESOLVER 类解析策略,从而在不改动整体框架的前提下堵住注入风险。压缩包内共 1 个文件,为单个 Java 源码文件,包体约 13KB,体量轻巧,便于直接放入项目对应目录替换使用。目前已有 1339 人学习下载,说明该方案在旧版本维护场景中具有一定参考价值。读者可从中获得一份可直接落地的漏洞修复代码,理解 Freemarker 模板类解析机制与安全配置方式,并据此排查自身项目中同类注入隐患,适合需要快速加固老系统的中高级 Java 开发者参考。

1. 模板渲染突然报错:为什么升级到 Freemarker 2.3.31 后页面直接 500

上周排查一个线上故障,现象很典型:某内容管理后台的模板渲染接口突然大面积返回 500,日志里刷的是freemarker.core.InvalidReferenceException和TemplateException,但模板文件一个字没改,代码也没动过。追下去才发现,是构建时把 Freemarker 从旧版本升到了 2.3.31,而项目里那个自定义的Configuration子类还在用老一套的setClassForTemplateLoading加setObjectWrapper组合,新版本对ObjectWrapper和TemplateClassResolver的默认行为收紧了,模板里一个原本能跑的空值引用直接炸了。

这类问题折腾人的地方在于:报错信息指向模板行号,但根因在Configuration的初始化逻辑里,新手照着行号去改模板只会越改越乱。Freemarker 模板注入漏洞的修复,本质上就是重写Configuration的装配方式,把TemplateClassResolver、ObjectWrapper、TemplateExceptionHandler这几个口子按安全基线重新配一遍。下面按我实际踩过的几个报错场景拆开讲,每个都给出能直接抄的配置和排查顺序。

2. 模板注入漏洞的根因:?new()和TemplateClassResolver到底放行了什么

2.1 漏洞触发的三个必要条件

Freemarker 模板注入(SSTI)能成立,通常要同时满足三个条件:模板内容可被外部影响、Configuration没有限制类解析、ObjectWrapper暴露了危险方法。很多项目只做了第一条的防护(比如模板存数据库、后台可编辑),却忽略了后两条,结果攻击者在模板里写${"freemarker.template.utility.Execute"?new()("id")}就能直接执行系统命令。

?new()这个内建函数是核心入口,它依赖TemplateClassResolver决定「哪些类允许被实例化」。2.3.31 之前的默认解析器是UNRESTRICTED_RESOLVER,基本不拦;2.3.31 起默认改成了SAFER_RESOLVER,会屏蔽ObjectConstructor、Execute、JythonRuntime等一批危险类。但如果你在代码里显式setNewBuiltinClassResolver(Configuration.UNRESTRICTED_RESOLVER),等于把新版本的默认防护又关掉了,这是最常见的翻车点。

2.2 重写Configuration的安全基线配置

我一般不在业务代码里零散地set一堆属性,而是继承Configuration做一个统一入口,把安全相关的开关一次性收口。下面这份是经过几个项目验证的基线:

import freemarker.cache.ClassTemplateLoader; import freemarker.cache.MultiTemplateLoader; import freemarker.cache.TemplateLoader; import freemarker.core.TemplateClassResolver; import freemarker.template.Configuration; import freemarker.template.DefaultObjectWrapperBuilder; import freemarker.template.TemplateExceptionHandler; public class SafeFreemarkerConfig extends Configuration { public SafeFreemarkerConfig() { super(Configuration.VERSION_2_3_31); // 1. 禁止 ?new() 实例化任意类,只允许白名单内的安全类 this.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER); // 2. 收紧对象包装器,禁止暴露 getClass() 等反射入口 DefaultObjectWrapperBuilder owb = new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_31); owb.setExposeFields(false); owb.setForceLegacyNonListCollections(false); this.setObjectWrapper(owb.build()); // 3. 模板异常统一转成运行时异常,避免把堆栈细节渲染到页面 this.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); // 4. 关闭 API 内建函数,堵住 ?api 绕过包装器直接拿 Java 对象的路径 this.setAPIBuiltinEnabled(false); // 5. 模板加载走 classpath,禁止从任意文件系统路径加载 TemplateLoader loader = new ClassTemplateLoader(this.getClass(), "/templates"); this.setTemplateLoader(new MultiTemplateLoader(new TemplateLoader[]{loader})); // 6. 输出编码固定,避免模板内容被错误解码后二次注入 this.setDefaultEncoding("UTF-8"); this.setOutputEncoding("UTF-8"); } }

逐条说下为什么这么配。SAFER_RESOLVER是 2.3.31 的安全默认值,它内部维护了一个黑名单,Execute、ObjectConstructor、JythonRuntime都会被拒绝实例化,这是堵?new()的第一道闸。setAPIBuiltinEnabled(false)很多人会漏,?api内建函数能绕过ObjectWrapper直接拿到对象的 Java API,等于给攻击者开了后门,必须关。RETHROW_HANDLER的作用是把模板异常原样抛出,由上层统一处理,而不是让 Freemarker 自己拼一段带堆栈的 HTML 返回给浏览器——后者在调试期方便,上线就是信息泄露。

DefaultObjectWrapperBuilder里setExposeFields(false)是防止模板通过${obj.someField}直接读私有字段,setForceLegacyNonListCollections(false)则是让集合类型按标准List/Map语义处理,减少包装器行为差异带来的意外暴露。

提示:如果你的项目还在用Configuration.VERSION_2_3_30及以下,SAFER_RESOLVER的行为和 2.3.31 不完全一致,升级时务必把incompatibleImprovements一起改到VERSION_2_3_31,否则部分安全默认值不会生效。

2.3 验证配置是否真的生效

配完不能只看代码,得实际打一发。写个最小验证模板,内容就一行${"freemarker.template.utility.Execute"?new()("whoami")},用你的SafeFreemarkerConfig去getTemplate再process,预期结果是抛TemplateException,提示类不被允许实例化。如果它正常输出了命令结果,说明TemplateClassResolver没生效,大概率是某处又调了setNewBuiltinClassResolver覆盖回去,或者incompatibleImprovements没设对。

import freemarker.template.Template; import java.io.StringWriter; import java.util.HashMap; public class InjectionSmokeTest { public static void main(String[] args) throws Exception { SafeFreemarkerConfig cfg = new SafeFreemarkerConfig(); // 从字符串加载一个恶意模板做冒烟验证 Template t = new Template("evil", "${"freemarker.template.utility.Execute"?new()("whoami")}", cfg); StringWriter out = new StringWriter(); try { t.process(new HashMap<>(), out); System.out.println("FAIL: 注入未拦截,输出=" + out); } catch (Exception e) { System.out.println("PASS: 已拦截 -> " + e.getMessage()); } } }

这段冒烟测试建议进 CI,每次改Configuration相关代码都跑一遍。Template构造函数直接吃字符串,不依赖模板文件,适合做单元测试;process的第二个参数是数据模型,这里传空HashMap即可,因为恶意表达式不依赖外部数据。

3. 升级后InvalidReferenceException刷屏:空值处理和ObjectWrapper的兼容坑

3.1 报错为什么在升级后才出现

InvalidReferenceException的典型信息是The following has evaluated to null or missing,指向模板里某个${user.name}之类的表达式。老版本里,如果ObjectWrapper是DefaultObjectWrapper且incompatibleImprovements较低,某些 null 场景会被静默处理成空字符串;2.3.31 把incompatibleImprovements拉到新值后,null 引用默认直接抛异常,于是原本「能跑但结果不对」的模板全暴露了。

这不是 bug,是行为收紧。正确的做法不是把异常处理器改回RETHROW_HANDLER之外的宽松模式,而是在模板层显式处理空值,同时在Configuration层统一配置默认值策略。

3.2 用setClassicCompatible之外的正当方式兜底

有人图省事会setClassicCompatible(true),这等于回到老版本的宽松语义,安全性和可维护性都倒退,不推荐。我一般用两个手段组合:模板里用!默认值操作符,配置层用setLogTemplateExceptions加自定义TemplateExceptionHandler做可观测。

// 配置层:记录模板异常但不吞掉,方便定位是哪个模板哪个变量 this.setTemplateExceptionHandler((te, env, out) -> { // 打印模板名和出错行,便于快速定位 System.err.println("Template error in " + env.getTemplate().getName() + " at line " + te.getCause().getMessage()); throw te; // 继续抛出,由全局异常处理接管 }); this.setLogTemplateExceptions(true);

模板侧则统一改成${user.name!""}或${user.name!"未知"},!后面跟默认值,null 时不再抛异常。批量改模板可以用脚本扫一遍${xxx}模式,但要注意别误伤${xxx?string}这类已经带内建函数的表达式。

3.3ObjectWrapper换实现后的类型不匹配

另一个高频报错是Can't convert ... to ...,出现在你把DefaultObjectWrapper换成BeansWrapper或自定义包装器之后。原因是不同包装器对Map、List、Date的默认适配策略不同,模板里${createTime?string("yyyy-MM-dd")}在旧包装器下能识别java.util.Date,新包装器可能把它当普通对象,?string就报类型错误。

排查顺序是:先看报错里的实际类型(freemarker.core.NonStringException会带上Expected ... but was ...),再对照你ObjectWrapper的setExposeFields、setSimpleMapWrapper等开关。我一般保持DefaultObjectWrapperBuilder不动,只调incompatibleImprovements,这样类型适配行为和官方默认一致,减少意外。

注意:setSimpleMapWrapper(true)会让Map的 key 直接当属性访问,方便但会绕过部分包装器检查,安全敏感场景建议保持默认false。

4. 避坑清单:重写Configuration时最容易翻车的五个点

4.1 坑一:子类构造里调super()顺序错,安全配置被覆盖

现象:明明在子类构造函数里设了SAFER_RESOLVER,冒烟测试还是能执行命令。原因:父类Configuration的某个set方法在super()之后又被业务代码里的另一个配置类调了一遍,把值改回去了。解决:把所有set收口到唯一一个SafeFreemarkerConfig,业务侧只new它,不再二次set;用grep -r "setNewBuiltinClassResolver"全局搜一遍确认没有第二处。

4.2 坑二:incompatibleImprovements没跟着版本走

现象:升级了 jar 包,但Configuration构造里还写着VERSION_2_3_23。原因:incompatibleImprovements决定了一大批默认行为,不跟着改,新版本的安全默认值不会启用。解决:构造参数和setIncompatibleImprovements统一用Configuration.VERSION_2_3_31,并在升级记录里单独列一条。

4.3 坑三:模板缓存把旧编译结果留在内存

现象:改完配置重启,部分请求还是走旧逻辑。原因:setTemplateUpdateDelayMilliseconds设得过大,或用了自定义CacheStorage没清。解决:开发环境设setTemplateUpdateDelayMilliseconds(0),生产环境确认缓存实现会在配置变更后失效;排查时先clearTemplateCache()再复现。

4.4 坑四:?api关了但?new白名单漏了自定义类

现象:SAFER_RESOLVER生效了,但攻击者仍能实例化项目里某个工具类。原因:SAFER_RESOLVER只拦已知危险类,项目自定义类不在黑名单里。解决:如果确实需要?new,用setNewBuiltinClassResolver传自定义TemplateClassResolver实现,只放行明确需要的类,其余一律拒绝。

4.5 坑五:异常处理器把堆栈渲染进页面

现象:线上 500 页面直接显示freemarker.core.开头的完整堆栈和模板路径。原因:用了TemplateExceptionHandler.HTML_DEBUG_HANDLER或DEBUG_HANDLER。解决:生产环境一律RETHROW_HANDLER,调试信息只进日志不进响应体。

5. 快速定位这类问题的排查路径:从报错行号反推到Configuration

排查 Freemarker 相关故障,我的固定顺序是「先看异常类型,再看模板行,最后回配置」。异常类型决定方向:InvalidReferenceException往空值和ObjectWrapper查,TemplateException带「class not allowed」往TemplateClassResolver查,NonStringException往包装器类型适配查。模板行号只是定位到表达式,真正的开关在Configuration的初始化代码里。

下面这张对照表是我贴在工位上的速查版,报错关键词对根因和第一动作:

报错关键词最可能根因第一动作
evaluated to null or missing空值未兜底 /incompatibleImprovements收紧模板加!默认值,确认版本号
class not allowed/Execute被拒TemplateClassResolver生效(正常)或白名单误配确认是否预期,检查自定义 resolver
Can't convert ... to ...ObjectWrapper换实现后类型不匹配对比包装器开关,回退到DefaultObjectWrapperBuilder
TemplateNotFoundException模板加载路径变了检查TemplateLoader根路径和MultiTemplateLoader顺序
页面出现完整堆栈异常处理器用了 DEBUG 模式改RETHROW_HANDLER,堆栈只进日志

验证手段上,我习惯做三层:单元测试跑注入冒烟(第 2.3 节那段),集成测试跑一遍核心模板渲染,上线前用curl打一个带边界数据的接口看返回是否含异常关键字。三层里任何一层挂了,都先回Configuration看set调用链,而不是去改模板——十次里有八次根因在配置装配,模板只是背锅的。

从那以后我每次动 Freemarker 版本或Configuration相关代码,都强制先跑一遍注入冒烟测试再提交,宁可多花五分钟,也不想再半夜被InvalidReferenceException叫醒。希望帮到你。

本文还有配套的精品资源,点击获取