简介:这份资源面向仍在使用老旧 JeecgBoot 版本、无法平滑升级的 Java 开发者,提供一种低成本修复 Freemarker 模板注入漏洞的应急方案。其核心做法是直接重写 freemarker 的 Configuration 类,在实例化方法中默认加入 this.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER),从而在不改动整体框架的前提下收紧模板解析策略,降低远程代码执行风险。压缩包内共 1 个 java 文件,即重写后的 Configuration.java,包体约 13KB,体量轻便,便于直接替换或对照修改。目前已有 1339 人学习下载,说明该思路在存量项目维护中具有一定参考价值。对于受限于历史版本、升级成本高的团队,这份源码可作为快速加固的起点,帮助理解 Freemarker 安全配置的落地方式,并据此排查项目中同类模板注入隐患。
1. 从一次模板注入告警说起:这个项目到底在修什么
去年帮一个做内容管理系统的团队排查线上问题,安全扫描报告里跳出一条 Freemarker 模板注入告警,定位到freemarker.template.Configuration的默认配置上。当时项目用的是 2.3.31 版本,模板里允许直接调用?new()这类内建函数,攻击者只要能把${"freemarker.template.utility.Execute"?new()("id")}这种字符串塞进模板,就能在服务端执行任意命令。这个项目的核心工作,就是重写Configuration的初始化逻辑,把new内建函数、ObjectConstructor、Execute这些危险入口全部封死,同时保证原有模板渲染功能不受影响。它适合正在用 Freemarker 做页面渲染、又需要过安全合规检查的 Java 后端开发者,尤其是那些模板内容部分来自用户输入或数据库的场景。
2. 漏洞根因拆解:为什么默认 Configuration 会成为注入入口
2.1 Freemarker 模板注入的触发链路
Freemarker 的模板语言本身支持一套内建函数,其中?new()允许在模板里实例化任意实现了TemplateModel的类。这个设计初衷是为了让模板能调用一些工具类,但问题在于它没有对类名做白名单限制。当模板内容可控时,攻击者可以构造出调用链,最终触达freemarker.template.utility.Execute或ObjectConstructor,前者直接执行系统命令,后者能反射创建任意对象。
触发链路大致是这样的:用户输入被拼进模板字符串 → 模板引擎解析时遇到?new()→ 通过ClassUtil.forName加载指定类 → 实例化并调用其exec方法。整条链路上,Configuration的newBuiltinClassResolver属性决定了哪些类允许被?new()访问,默认值是TemplateClassResolver.UNRESTRICTED_RESOLVER,也就是不限制。
// Freemarker 2.3.31 中 Configuration 的默认行为(简化示意) public class Configuration { private TemplateClassResolver newBuiltinClassResolver = TemplateClassResolver.UNRESTRICTED_RESOLVER; // 危险默认值 public void setNewBuiltinClassResolver(TemplateClassResolver resolver) { this.newBuiltinClassResolver = resolver; } }这段代码说明默认配置下,?new()可以加载任意类。参数newBuiltinClassResolver是控制入口,只要把它替换成TemplateClassResolver.SAFER_RESOLVER或自定义解析器,就能阻断大部分攻击。但很多项目在初始化Configuration时只设置了模板加载路径和编码,根本没碰这个属性,漏洞就这么留下来了。
2.2 为什么直接升级版本不一定能解决
Freemarker 后续版本确实收紧了默认策略,但实际项目里升级模板引擎版本往往牵一发动全身。模板语法兼容性、自定义指令、宏定义的行为都可能变化,尤其是那些模板里已经用了?new()做合法工具调用的老系统,升级后直接报错。所以这个项目选择重写Configuration,在 2.3.31 的基础上做安全加固,而不是简单升级。
重写的思路是:继承Configuration类,在构造阶段就把newBuiltinClassResolver设成白名单模式,同时覆盖setNewBuiltinClassResolver方法,防止后续代码把它改回不安全的值。这样既保留了原有 API 兼容性,又把安全策略固化下来。
public class SafeConfiguration extends Configuration { // 白名单:只允许模板访问这些工具类 private static final Set<String> ALLOWED_CLASSES = new HashSet<>(Arrays.asList( "freemarker.template.utility.DateUtil", "freemarker.template.utility.StringUtil" )); public SafeConfiguration() { super(); // 构造时立即锁定安全解析器 super.setNewBuiltinClassResolver(new WhitelistClassResolver()); } @Override public void setNewBuiltinClassResolver(TemplateClassResolver resolver) { // 禁止外部覆盖,保持白名单策略 throw new UnsupportedOperationException( "newBuiltinClassResolver is locked for security"); } }这里的关键点是构造器里用super.setNewBuiltinClassResolver绕过自己的重写方法,完成初始化后再禁止外部修改。ALLOWED_CLASSES集合就是白名单,只有明确列出的类才允许被?new()实例化。实际项目中,这个白名单要根据模板里真实用到的工具类来定,不能照抄。
2.3 白名单解析器的实现细节
TemplateClassResolver是一个接口,只有一个方法resolve,返回允许加载的Class对象。自定义解析器要实现这个方法,在里面做白名单校验。
public class WhitelistClassResolver implements TemplateClassResolver { @Override public Class<?> resolve(String className, Environment env, Template template) throws TemplateException { if (ALLOWED_CLASSES.contains(className)) { return ClassUtil.forName(className); } // 不在白名单内,抛出异常并记录日志 throw new TemplateException( "Class " + className + " is not allowed in template", env); } }resolve方法的三个参数分别是类名、当前环境对象和模板对象。类名来自模板里的?new()调用,环境对象可以用来获取配置信息,模板对象能拿到模板名称方便定位问题。校验逻辑很简单:在白名单里就放行,否则抛异常。异常信息里带上类名,方便排查是哪个模板触发了拦截。
注意:白名单校验要区分大小写,并且不能只做前缀匹配。曾经见过有人用
startsWith("freemarker.template.utility")做校验,结果攻击者用freemarker.template.utility.Execute照样能过,因为Execute也在这个包下。
3. 工程落地:从目录结构到运行验证的完整流程
3.1 项目目录结构与模块划分
这个项目的代码结构比较紧凑,核心就三个包:config放安全配置类,resolver放白名单解析器,test放验证用例。典型的 Maven 项目布局如下:
freemarker-security/ ├── pom.xml ├── src/ │ ├── main/ │ │ └── java/ │ │ └── com/ │ │ └── example/ │ │ └── freemarker/ │ │ ├── config/ │ │ │ └── SafeConfiguration.java │ │ └── resolver/ │ │ └── WhitelistClassResolver.java │ └── test/ │ └── java/ │ └── com/ │ └── example/ │ └── freemarker/ │ └── SecurityTest.javapom.xml里只需要引入 Freemarker 2.3.31 和 JUnit,不需要额外依赖。SafeConfiguration是核心类,WhitelistClassResolver是配套的解析器,测试类用来验证拦截效果。
<dependencies> <dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</artifactId> <version>2.3.31</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.13.2</version> <scope>test</scope> </dependency> </dependencies>依赖版本要锁死,避免构建时拉到其他版本导致行为不一致。JUnit 只用于测试,生产环境不需要。
3.2 安全配置类的初始化与参数说明
SafeConfiguration除了锁定解析器,还需要设置模板加载路径、编码、异常处理等常规参数。这些参数在构造器里统一初始化,避免调用方遗漏。
public SafeConfiguration(String templatePath) throws IOException { super(); // 设置模板加载目录 setDirectoryForTemplateLoading(new File(templatePath)); // 统一编码,防止乱码导致的解析异常 setDefaultEncoding("UTF-8"); // 模板异常时抛出,不静默失败 setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); // 锁定安全解析器 super.setNewBuiltinClassResolver(new WhitelistClassResolver()); // 禁用对象包装器的危险方法 setObjectWrapper(new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_31) .build()); }setDirectoryForTemplateLoading指定模板文件所在目录,setDefaultEncoding统一用 UTF-8,setTemplateExceptionHandler设为RETHROW_HANDLER让异常直接抛出,方便定位问题。setObjectWrapper用DefaultObjectWrapperBuilder构建,避免使用DefaultObjectWrapper的旧构造方式,后者在某些版本里会暴露不安全的反射入口。
参数templatePath是模板目录的绝对路径或相对路径,建议用绝对路径,避免工作目录变化导致找不到模板。VERSION_2_3_31是 Freemarker 的版本常量,用来保证对象包装器的行为与引擎版本匹配。
3.3 运行验证:用攻击载荷测试拦截效果
写完配置类后,需要写测试用例验证拦截是否生效。测试用例分两组:一组是正常模板渲染,确保功能没被误伤;另一组是攻击载荷,确保被正确拦截。
@Test public void testNormalTemplate() throws Exception { SafeConfiguration cfg = new SafeConfiguration("src/test/resources/templates"); Template template = new Template("hello", "Hello ${name}!", cfg); StringWriter writer = new StringWriter(); template.process(Collections.singletonMap("name", "World"), writer); assertEquals("Hello World!", writer.toString()); } @Test(expected = TemplateException.class) public void testExecuteInjection() throws Exception { SafeConfiguration cfg = new SafeConfiguration("src/test/resources/templates"); String payload = "${"freemarker.template.utility.Execute"?new()("id")}"; Template template = new Template("attack", payload, cfg); template.process(new HashMap<>(), new StringWriter()); }第一个用例渲染一个普通模板,验证变量替换正常。第二个用例构造Execute注入载荷,期望抛出TemplateException。如果第二个用例没有抛异常,说明白名单没生效,需要检查WhitelistClassResolver的resolve方法是否被正确调用。
运行测试用mvn test即可。如果测试通过,说明安全加固生效;如果失败,看异常堆栈里是哪个类被加载了,再调整白名单或解析器逻辑。
提示:测试载荷里的
"id"只是示例命令,实际验证时可以用"echo test"这类无害命令,避免在测试环境执行敏感操作。
4. 避坑与排查:模板注入加固中的常见问题
4.1 白名单遗漏导致正常模板报错
现象:加固后部分模板渲染失败,日志里出现Class xxx is not allowed in template。原因:白名单只列了部分工具类,模板里用到的其他合法类被拦截。解决:先在全量模板里搜索?new()调用,把所有用到的类名收集起来,逐个确认安全性后加入白名单。不要图省事用包名前缀放行,那等于没加固。
4.2 子类重写方法被绕过
现象:明明继承了SafeConfiguration,但攻击载荷还是能执行。原因:调用方通过反射直接修改了父类的newBuiltinClassResolver字段,或者用了Configuration的其他 setter 方法间接改回不安全值。解决:在SafeConfiguration里把所有可能影响解析器的 setter 都重写并抛异常,同时用final修饰关键字段。如果调用方坚持要改,说明它不该用这个安全配置类。
4.3 模板缓存导致旧配置生效
现象:改了配置类重新部署,攻击载荷仍然能过。原因:Freemarker 的Configuration实例被缓存了,或者模板本身被缓存,新配置没应用到已缓存的模板上。解决:在应用启动时重新创建Configuration实例,并调用clearTemplateCache()清空模板缓存。如果是 Spring 项目,检查Configuration的 Bean 作用域,确保不是单例被其他模块提前初始化。
4.4 对象包装器暴露反射入口
现象:白名单解析器生效了,但通过${obj.getClass().forName("...")}这类方式仍然能触达危险类。原因:DefaultObjectWrapper默认允许模板访问对象的getClass()方法,进而拿到Class对象做反射。解决:在SafeConfiguration里设置setObjectWrapper时,用DefaultObjectWrapperBuilder并调用setExposeFields(false)和setExposeProperties(false),或者自定义ObjectWrapper限制可访问的方法。
4.5 日志泄露敏感信息
现象:拦截异常里打印了完整的类名和模板内容,日志被攻击者利用。原因:TemplateException的默认消息包含模板片段和类名。解决:在WhitelistClassResolver里捕获异常后,只记录类名和模板名称,不记录模板内容。生产环境的日志级别调到 WARN 以上,避免异常堆栈被外部看到。
5. 二次开发与改造:把安全配置做成可复用的 Starter
5.1 从单项目配置到通用 Starter
如果团队里有多个 Java 项目都用 Freemarker,把SafeConfiguration做成 Spring Boot Starter 是最省事的做法。核心思路是提供一个@Configuration类,自动装配SafeConfigurationBean,并通过application.yml暴露白名单配置项。
@Configuration @ConditionalOnClass(Configuration.class) public class FreemarkerSecurityAutoConfiguration { @Bean @ConditionalOnMissingBean public SafeConfiguration safeConfiguration( FreemarkerSecurityProperties properties) throws IOException { SafeConfiguration cfg = new SafeConfiguration(properties.getTemplatePath()); // 从配置文件加载额外白名单 properties.getAdditionalWhitelist().forEach( WhitelistClassResolver::addAllowedClass); return cfg; } }FreemarkerSecurityProperties用@ConfigurationProperties绑定freemarker.security前缀的配置,additionalWhitelist允许各项目按需追加白名单类。@ConditionalOnMissingBean保证业务方可以覆盖默认配置,@ConditionalOnClass保证没有 Freemarker 依赖时自动跳过。
5.2 白名单的动态管理
硬编码白名单在项目初期够用,但模板多了之后维护成本高。可以把白名单放到数据库或配置中心,启动时加载,运行时支持刷新。改造点是WhitelistClassResolver的ALLOWED_CLASSES从静态集合改成可刷新的ConcurrentHashMap,并暴露addAllowedClass和removeAllowedClass方法。
public class WhitelistClassResolver implements TemplateClassResolver { private final Set<String> allowedClasses = ConcurrentHashMap.newKeySet(); public void addAllowedClass(String className) { allowedClasses.add(className); } public void removeAllowedClass(String className) { allowedClasses.remove(className); } @Override public Class<?> resolve(String className, Environment env, Template template) throws TemplateException { if (allowedClasses.contains(className)) { return ClassUtil.forName(className); } throw new TemplateException("Blocked: " + className, env); } }用ConcurrentHashMap.newKeySet()保证并发安全,addAllowedClass和removeAllowedClass可以在配置刷新时调用。注意刷新后要清空模板缓存,否则已缓存的模板仍然用旧白名单。
5.3 验证方法与回归测试
改造完成后,回归测试要覆盖三类场景:正常模板渲染、白名单内类调用、白名单外类拦截。建议把攻击载荷做成参数化测试,每次新增白名单类时都跑一遍,确保没有引入新的绕过路径。
| 测试场景 | 输入 | 预期结果 |
|---|---|---|
| 普通变量替换 | ${name} | 正常输出 |
| 白名单类调用 | ${"freemarker.template.utility.DateUtil"?new()} | 正常实例化 |
| 危险类拦截 | ${"freemarker.template.utility.Execute"?new()} | 抛 TemplateException |
| 反射绕过 | ${obj.getClass().forName("...")} | 抛异常或返回受限对象 |
这张表可以直接转成 JUnit 参数化测试的用例集。每次改白名单或解析器逻辑,先跑这张表,全绿了再上预发环境。
从那以后我每次给 Freemarker 做安全加固,都强制走一遍「收集?new()调用 → 确认白名单 → 跑攻击载荷 → 清缓存重启」的流程,少一步都可能留口子。希望帮到你。
本文还有配套的精品资源,点击获取