简介:这份资源面向仍在使用老版本 jeecgboot、无法平滑升级的 Java 开发者,针对 Freemarker 模板注入漏洞提供一种低成本修复思路:直接重写 freemarker 的 Configuration 类,在实例化方法中默认加入 SAFER_RESOLVER 类解析策略,从而在不改动整体框架的前提下收紧模板解析权限。压缩包内共 1 个文件,为单个 Java 源码文件,包体约 13KB,体量轻巧,便于直接放入项目对应目录替换使用。目前已有 1339 人学习下载,说明该方案在旧版本维护场景中具有一定参考价值。读者可从中获得一份可直接落地的漏洞修复代码,理解 Freemarker 模板注入的成因与 SAFER_RESOLVER 的作用机制,并据此排查自身项目中同类配置风险,适合需要快速加固老系统、又缺乏升级条件的中高级 Java 开发者参考。
1. 重写 Configuration 的动机:为什么模板注入不能只靠“升级版本”糊弄过去
去年帮一个做内容站的朋友排查线上问题,日志里出现了一段不该出现的表达式执行痕迹,追下去发现是 Freemarker 模板里被塞了可执行的表达式。很多人第一反应是“把 freemarker 升到 2.3.31 就完事了”,但实际翻车点在于:版本升级只堵住了默认配置下的已知入口,项目里只要有人自定义过Configuration、改过TemplateClassResolver、或者把模板来源接到了用户可控的目录,注入面依然敞着。这篇笔记拆的是一个很具体的落地动作——重写freemarker.template.Configuration,把模板加载、类解析、对象包装这几条链路收紧,让模板注入在配置层就被掐掉。适合正在用 Freemarker 做页面渲染、又需要把模板交给运营或半可信来源维护的 Java 后端同学,尤其是那些“升级了依赖但心里没底”的场景。
2. 先看清注入面:Freemarker 的三个可被利用入口
动手重写之前得先明白,模板注入不是单一漏洞,而是一组能力被滥用。Freemarker 本身是模板引擎,它的设计目标就是“让模板能访问数据模型”,问题出在模板作者能触达的边界比业务预期宽。常见被利用的入口有三个:?new这类内建函数能实例化任意类、TemplateClassResolver决定哪些类允许被解析、以及ObjectWrapper把 Java 对象暴露给模板时的可见性控制。这三者任意一个放松,模板里就可能出现意料之外的执行路径。
2.1 内建函数与 new 的默认行为
Freemarker 的?new内建函数允许模板直接构造对象,比如${"freemarker.template.utility.Execute"?new()("id")}这种写法在旧配置下是能跑通的。2.3.31 默认把newBuiltinClassResolver设成了TemplateClassResolver.ALLOWS_NOTHING_RESOLVER,也就是默认禁止?new解析任何类。但注意,这是“默认值”,只要项目里有人为了某个历史模板显式设过ALLOWS_ALL_RESOLVER或者自定义了一个宽松的 resolver,升级依赖并不会覆盖这行代码。所以第一件事是全局搜一遍setNewBuiltinClassResolver和setAPIBuiltinEnabled,确认没有残留的宽松设置。
2.2 TemplateClassResolver 的选型
TemplateClassResolver控制的是模板里通过?new和?api能解析到哪些类。Freemarker 提供了几个现成的:ALLOWS_NOTHING_RESOLVER(全禁)、ALLOWS_SAFER_RESOLVER(只允许Object、String、Number等安全类)、UNRESTRICTED_RESOLVER(全放开,危险)。生产环境我一般直接用ALLOWS_NOTHING_RESOLVER,因为绝大多数业务模板根本不需要在模板里 new 对象,需要构造逻辑就该放在 Java 侧。如果确实有模板依赖?new构造某个 DTO,那也应该用ALLOWS_SAFER_RESOLVER并配合白名单,而不是图省事全放开。
2.3 ObjectWrapper 的暴露边界
ObjectWrapper决定了数据模型里的 Java 对象在模板里能看到哪些方法。默认的DefaultObjectWrapper会把 public 方法都暴露出去,包括getClass()、getClassLoader()这类不该给模板看的方法。攻击者拿到getClass()之后可以顺着反射链往下走,这是很多模板注入利用链的起点。收紧的办法是自定义ObjectWrapper,在wrap()里对敏感方法做过滤,或者干脆用BeansWrapper的EXPOSE_SAFE模式,只暴露 getter/setter 和明确允许的方法。这一步是重写Configuration时最容易被忽略、但收益最高的动作。
3. 重写 Configuration:把安全策略焊进配置类
理解了入口,接下来就是落地。重写Configuration不是让你从零实现一个模板引擎,而是继承freemarker.template.Configuration,在构造和初始化阶段把安全相关的 setter 一次性设死,避免业务代码里零散地改配置。这样做的好处是:安全基线只有一个来源,代码评审时一眼能看出有没有人绕过。
3.1 继承 Configuration 并锁定关键 setter
下面这个类是我在项目里用的骨架,核心思路是构造时就把 resolver、API 开关、对象包装器定好,同时对可能被外部调用的 setter 做覆盖,直接抛异常或者忽略。注意Configuration的很多 setter 是 public 的,子类可以覆盖它们来阻止运行时被改。
import freemarker.template.Configuration; import freemarker.template.DefaultObjectWrapperBuilder; import freemarker.template.TemplateExceptionHandler; import freemarker.core.TemplateClassResolver; public class HardenedConfiguration extends Configuration { public HardenedConfiguration() { super(Configuration.VERSION_2_3_31); // 禁止 ?new 解析任何类 setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER); // 关闭 ?api,模板里不能直接调 Java API setAPIBuiltinEnabled(false); // 对象包装器收紧:只暴露安全方法 DefaultObjectWrapperBuilder builder = new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_31); builder.setExposeFields(false); builder.setMethodsShadowItems(true); setObjectWrapper(builder.build()); // 模板异常统一处理,避免把堆栈细节吐到页面 setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); // 关闭模板缓存之外的调试输出 setLogTemplateExceptions(false); setWrapUncheckedExceptions(true); } @Override public void setNewBuiltinClassResolver(TemplateClassResolver resolver) { // 允许构造阶段设置,但运行期禁止改成宽松策略 if (resolver != TemplateClassResolver.ALLOWS_NOTHING_RESOLVER) { throw new UnsupportedOperationException("resolver is locked"); } super.setNewBuiltinClassResolver(resolver); } @Override public void setAPIBuiltinEnabled(boolean value) { if (value) { throw new UnsupportedOperationException("api builtin is locked off"); } super.setAPIBuiltinEnabled(value); } }这段代码的逻辑说明:构造函数里把newBuiltinClassResolver设成ALLOWS_NOTHING_RESOLVER,意味着模板里任何?new都会失败;setAPIBuiltinEnabled(false)关掉?api,堵住直接调 Java 方法的路径;DefaultObjectWrapperBuilder关掉字段暴露、开启方法遮蔽,减少反射链的可用面。覆盖setNewBuiltinClassResolver和setAPIBuiltinEnabled是为了防止业务代码在运行期把策略改回去——这两个 setter 是注入面最直接的开关,锁死它们等于给配置上了保险。参数上,VERSION_2_3_31要和依赖版本对齐,setLogTemplateExceptions(false)是为了不把异常细节写进日志被外部看到,setWrapUncheckedExceptions(true)让运行时异常以模板异常形式抛出,便于统一拦截。
3.2 模板加载器的来源约束
光锁 resolver 还不够,模板从哪加载同样关键。如果TemplateLoader指向了一个用户可写的目录,攻击者能直接上传模板文件,那前面所有配置都白搭。常见做法是用FileTemplateLoader时把根目录限定在应用内的只读路径,或者用ClassTemplateLoader从 classpath 加载,彻底断掉外部写入的可能。下面是一个按环境区分的加载器配置片段。
import freemarker.cache.ClassTemplateLoader; import freemarker.cache.FileTemplateLoader; import freemarker.cache.MultiTemplateLoader; import freemarker.cache.TemplateLoader; import java.io.File; public class TemplateLoaderFactory { public static TemplateLoader build(String env, String templateDir) throws Exception { if ("prod".equals(env)) { // 生产环境只从 classpath 读,模板随包发布,外部不可写 return new ClassTemplateLoader(TemplateLoaderFactory.class, "/templates"); } // 开发环境允许本地目录,但限定在项目内 File dir = new File(templateDir).getCanonicalFile(); if (!dir.getPath().contains("src/main/resources/templates")) { throw new IllegalArgumentException("template dir out of allowed scope"); } return new MultiTemplateLoader(new TemplateLoader[]{ new FileTemplateLoader(dir), new ClassTemplateLoader(TemplateLoaderFactory.class, "/templates") }); } }逻辑说明:生产环境用ClassTemplateLoader,模板打进 jar,运行期没有写入入口,从源头上消除了“上传恶意模板”的可能;开发环境用FileTemplateLoader但做了 canonical path 校验,防止通过../跳出允许目录。参数上,templateDir必须来自配置而非请求参数,getCanonicalFile()是为了解析符号链接和相对路径,避免路径穿越。MultiTemplateLoader的顺序也有讲究,本地目录在前便于开发调试,classpath 兜底保证生产模板始终可用。
3.3 数据模型侧的净化
配置层收紧之后,还有一层是数据模型。模板拿到的Map<String, Object>里如果混进了Class、ClassLoader、Method这类对象,即便 resolver 禁了?new,攻击者仍可能通过对象自身的方法链做文章。我一般会在渲染前对模型做一次浅层净化,把不允许的类型替换成字符串或者直接剔除。
import java.util.HashMap; import java.util.Map; public class ModelSanitizer { public static Map<String, Object> sanitize(Map<String, Object> raw) { Map<String, Object> clean = new HashMap<>(); for (Map.Entry<String, Object> e : raw.entrySet()) { Object v = e.getValue(); if (v == null) { continue; } // 禁止 Class / ClassLoader / Method 等反射相关类型进入模板 String type = v.getClass().getName(); if (type.startsWith("java.lang.Class") || type.startsWith("java.lang.reflect") || v instanceof ClassLoader) { continue; } clean.put(e.getKey(), v); } return clean; } }这段净化逻辑不追求深度遍历,只做顶层过滤,因为深层对象由ObjectWrapper的可见性控制兜底。参数上,type.startsWith("java.lang.Class")同时覆盖了Class和ClassLoader的子类场景,java.lang.reflect前缀拦住Method、Field等反射入口。实际项目里如果模型结构固定,更推荐用 DTO 而不是裸Map,让类型在编译期就受控。
4. 避坑与排查:升级之后仍然中招的几种情况
配置写完了不代表就安全了,实际排查中遇到的坑往往不在代码本身,而在“以为改了其实没生效”。下面几条是血泪经验里最常复现的。
4.1 现象:升级依赖后?new依然能执行
原因:项目里某个历史配置类显式调用了setNewBuiltinClassResolver(UNRESTRICTED_RESOLVER),或者用了Configuration的静态setDefaultConfiguration被别处覆盖。解决:全局搜setNewBuiltinClassResolver、setAPIBuiltinEnabled、new Configuration(三个关键词,确认没有绕过HardenedConfiguration的实例化路径;同时检查是否有@Bean返回的是原生Configuration而不是子类。
4.2 现象:模板渲染报UnsupportedOperationException: resolver is locked
原因:某个业务模块在运行期试图改 resolver,被覆盖的 setter 拦下了。解决:这其实是预期行为,说明有代码在动安全开关。顺着异常堆栈找到调用方,把那段配置删掉,逻辑迁到HardenedConfiguration构造阶段统一处理。不要为了“让它跑起来”去放开锁。
4.3 现象:生产环境模板加载失败,报找不到模板
原因:切到ClassTemplateLoader后,模板路径基准变了,原来相对文件系统的路径在 classpath 下对不上。解决:确认模板在 jar 内的实际路径,ClassTemplateLoader的第二个参数是相对 classpath 根的目录,比如/templates对应src/main/resources/templates。用getResourceAsStream先验证路径存在,再改加载器。
4.4 现象:净化后页面某些字段变空
原因:ModelSanitizer把Class类型剔除了,但业务模板里确实有展示类名字段的需求(比如调试页)。解决:区分环境,生产环境走净化,调试环境保留但限制访问来源;或者把需要展示的类信息在 Java 侧转成字符串再放进模型,而不是把Class对象直接塞进去。
4.5 现象:异常日志里出现模板表达式片段
原因:TemplateExceptionHandler设成了DEBUG_HANDLER或者默认的HTML_DEBUG_HANDLER,把出错位置的模板源码打进了响应或日志。解决:统一用RETHROW_HANDLER,由全局异常处理接管,日志里只记录模板名和行号,不记录表达式内容。这一条在排查注入时尤其重要,日志本身也可能成为信息泄露渠道。
5. 验证与进阶:用测试用例把安全基线钉死
配置改完之后,最怕的是“下次有人手滑改回去”。我的习惯是给HardenedConfiguration配一组单元测试,把几个关键行为钉成断言,CI 跑不过就不让合并。下面是一个用 JUnit 5 写的验证片段,覆盖?new被禁、?api被禁、宽松 setter 被锁三个点。
import freemarker.template.Template; import org.junit.jupiter.api.Test; import java.io.StringReader; import java.io.StringWriter; import java.util.HashMap; import static org.junit.jupiter.api.Assertions.*; public class HardenedConfigurationTest { @Test void newBuiltinShouldFail() throws Exception { HardenedConfiguration cfg = new HardenedConfiguration(); Template t = new Template("t", new StringReader( "${'freemarker.template.utility.Execute'?new()('id')}"), cfg); assertThrows(Exception.class, () -> t.process(new HashMap<>(), new StringWriter())); } @Test void apiBuiltinShouldFail() throws Exception { HardenedConfiguration cfg = new HardenedConfiguration(); Template t = new Template("t", new StringReader( "${'abc'?api.getClass()}"), cfg); assertThrows(Exception.class, () -> t.process(new HashMap<>(), new StringWriter())); } @Test void resolverSetterShouldBeLocked() { HardenedConfiguration cfg = new HardenedConfiguration(); assertThrows(UnsupportedOperationException.class, () -> cfg.setNewBuiltinClassResolver( freemarker.core.TemplateClassResolver.UNRESTRICTED_RESOLVER)); } }逻辑说明:第一个用例构造一个带?new的模板,断言process抛异常,证明 resolver 生效;第二个用例用?api调getClass(),断言失败,证明 API 开关关闭;第三个用例直接调宽松 setter,断言抛UnsupportedOperationException,证明锁生效。参数上,new Template(...)直接传字符串避免依赖文件系统,assertThrows(Exception.class, ...)用宽泛异常类型是因为 Freemarker 在不同阶段抛的异常子类可能不同,断言“失败”本身比断言具体类型更稳。
除了测试,还有一个进阶技巧:把HardenedConfiguration注册成 Spring 的单例 Bean,并在启动时打印一次关键配置的实际值(resolver 类型、API 开关、ObjectWrapper 类名),作为启动自检日志。这样每次发布都能在日志里确认安全基线没被覆盖,比事后排查省事得多。我一般会在@PostConstruct里做这件事,输出到独立的 logger,方便运维检索。
从那以后我每次接 Freemarker 相关项目,第一件事不是看业务模板,而是先把Configuration的实例化路径和 setter 调用点过一遍,确认没有绕过硬化配置的入口,再谈渲染逻辑。这个习惯帮我省掉了好几次“升级了但没完全升级”的返工。希望帮到你。
本文还有配套的精品资源,点击获取