Freemarker 2.3.31 模板注入漏洞修复:重写 Configuration 安全加固实战
简介这份资源面向仍在使用老旧 JeecgBoot 版本、无法平滑升级的 Java 开发者针对 Freemarker 模板注入漏洞提供一种低成本修复思路。其核心做法是直接重写 freemarker 的 Configuration 类在实例化方法中默认加入 this.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER)从而收紧模板解析行为、降低注入风险。压缩包内共 1 个文件为单个 java 源码文件整体约 13KB体量轻巧便于直接放入项目对应目录替换使用。目前已有 1339 人学习下载说明该方案在旧系统维护场景中具有一定参考价值。对于受制于历史版本、难以整体升级的项目这份源码可作为快速加固的落地参考帮助开发者理解 SAFER_RESOLVER 的接入位置与生效方式并据此排查同类模板注入隐患。1. 从一次模板注入告警说起为什么要重写 Configuration去年某个内部系统做安全扫描报出一个 Freemarker 模板注入漏洞版本正好卡在 2.3.31。当时第一反应是升级版本但项目里对 Freemarker 的 API 做了大量封装直接升级牵一发动全身。后来仔细看告警详情问题出在Configuration的默认配置上——setAPIBuiltinEnabled、setNewBuiltinClassResolver这些开关没关严攻击者可以通过模板里的?api、?new拿到任意类实例进而执行命令。这个项目的思路不是升级而是直接重写Configuration的初始化逻辑把危险入口全部堵死同时保持原有模板渲染行为不变。适合正在用 Freemarker 做页面渲染、又不想大动版本的后端开发者参考。2. 漏洞根因拆解Freemarker 2.3.31 的哪些默认值在裸奔2.1 三个高危内置函数api、new、evalFreemarker 模板语言本身是安全的它只能访问数据模型里显式放进去的对象。但 2.3.31 默认开放了几个后门内置函数?api把一个对象包装成TemplateModel的原始 Java 对象暴露出来攻击者可以顺着它调用任意方法。?new直接实例化任意类只要类在 classpath 里。?eval把字符串当模板再解析一次配合前两个可以构造复杂攻击链。这三个函数在默认Configuration下是开启的。很多项目只设置了模板目录和编码根本没碰这几个开关扫描器一打一个准。2.2 默认 ObjectWrapper 的宽松策略Configuration默认使用DefaultObjectWrapper它会把 Java 对象的方法暴露给模板。比如数据模型里放了一个User对象模板里可以直接写${user.getClass().forName(java.lang.Runtime)}。虽然getClass()在较新版本被限制但 2.3.31 的默认行为仍然偏宽松。常见做法是换成DefaultObjectWrapperBuilder并设置setExposeFields(false)、setMemberAccessPolicy来收紧。2.3 模板加载器的路径穿越风险如果TemplateLoader用的是FileTemplateLoader且没有限制根目录攻击者可以通过../读取任意文件。2.3.31 的FileTemplateLoader默认会做一定检查但如果自己继承了TemplateLoader实现很容易漏掉路径规范化。提示不要只依赖版本升级很多漏洞是配置问题升级后默认值变了反而可能影响业务。3. 重写 Configuration从初始化到渲染的完整改造3.1 继承 Configuration 并覆盖关键 setter最直接的做法是写一个SafeConfiguration extends Configuration在构造函数里把所有危险开关关掉。这样项目里所有new Configuration()的地方换成new SafeConfiguration()即可改动面小。import freemarker.template.Configuration; import freemarker.template.DefaultObjectWrapperBuilder; import freemarker.template.TemplateExceptionHandler; import freemarker.core.TemplateClassResolver; public class SafeConfiguration extends Configuration { public SafeConfiguration() { super(Configuration.VERSION_2_3_31); // 1. 禁用 api 内置函数 this.setAPIBuiltinEnabled(false); // 2. 设置安全的类解析器禁止 ?new 实例化任意类 this.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER); // 3. 收紧对象包装策略 DefaultObjectWrapperBuilder owb new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_31); owb.setExposeFields(false); owb.setMemberAccessPolicy(new SafeMemberAccessPolicy()); this.setObjectWrapper(owb.build()); // 4. 模板异常处理避免把堆栈暴露到页面 this.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); // 5. 关闭一些不必要的行为 this.setLogTemplateExceptions(false); this.setWrapUncheckedExceptions(true); this.setFallbackOnNullLoopVariable(false); } }这段代码的关键点setAPIBuiltinEnabled(false)直接让?api失效模板里写${obj?api}会抛异常。TemplateClassResolver.SAFER_RESOLVER是 Freemarker 提供的一个白名单解析器只允许java.lang.*下的少数安全类?new基本废掉。SafeMemberAccessPolicy需要自己实现用来控制哪些方法可以被模板调用。下面会讲。setLogTemplateExceptions(false)防止异常信息写日志时泄露内部路径。3.2 自定义 MemberAccessPolicy 做方法级白名单Freemarker 2.3.31 提供了MemberAccessPolicy接口可以精确控制模板能访问哪些方法。默认策略比较宽松我们改成只允许 getter 和特定安全方法。import freemarker.core.MemberAccessPolicy; import freemarker.core.MemberAccessPolicy.AccessDecision; import freemarker.core.MemberAccessPolicy.MemberAccess; import java.lang.reflect.Member; import java.lang.reflect.Method; public class SafeMemberAccessPolicy implements MemberAccessPolicy { Override public AccessDecision decideAccess(MemberAccess memberAccess) { Member member memberAccess.getMember(); // 只处理方法 if (!(member instanceof Method)) { return AccessDecision.ALLOW; // 字段由 ObjectWrapper 的 exposeFields 控制 } Method method (Method) member; String name method.getName(); // 允许 getter 和 is 开头的布尔 getter if (name.startsWith(get) || name.startsWith(is)) { // 排除 getClass防止反射链 if (getClass.equals(name)) { return AccessDecision.DENY; } return AccessDecision.ALLOW; } // 允许 toString模板调试常用 if (toString.equals(name)) { return AccessDecision.ALLOW; } // 其他方法一律拒绝 return AccessDecision.DENY; } }逻辑说明decideAccess对每个模板访问的成员调用一次返回ALLOW或DENY。只放行 getter 和toString这样${user.name}能正常工作但${user.delete()}会被拒绝。显式拒绝getClass堵住反射入口。字段访问由DefaultObjectWrapperBuilder.setExposeFields(false)控制这里不重复处理。参数说明MemberAccess对象包含getMember()和getTemplateModel()一般只需要看getMember()。3.3 模板加载器的路径安全加固如果项目用FileTemplateLoader建议包一层做路径校验。常见做法是继承FileTemplateLoader并覆盖findTemplateSource。import freemarker.cache.FileTemplateLoader; import java.io.File; import java.io.IOException; public class SafeFileTemplateLoader extends FileTemplateLoader { private final File baseDir; public SafeFileTemplateLoader(File baseDir) throws IOException { super(baseDir); this.baseDir baseDir.getCanonicalFile(); } Override public Object findTemplateSource(String name) throws IOException { // 规范化路径防止 ../ 穿越 File target new File(baseDir, name).getCanonicalFile(); if (!target.getPath().startsWith(baseDir.getPath())) { throw new IOException(Template path out of base directory: name); } return super.findTemplateSource(name); } }这段代码的作用构造函数里把baseDir转成规范路径避免符号链接绕过。findTemplateSource里先拼出目标文件再规范化如果不在baseDir下直接抛异常。这样即使模板名里带../../etc/passwd也会被拦住。3.4 在 Spring Boot 里替换默认 Configuration如果项目是 Spring Boot通常通过FreeMarkerConfigurer配置。把上面的SafeConfiguration注入进去即可。import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer; Configuration public class FreemarkerSafeConfig { Bean public FreeMarkerConfigurer freeMarkerConfigurer() throws Exception { FreeMarkerConfigurer configurer new FreeMarkerConfigurer(); configurer.setTemplateLoaderPath(classpath:/templates/); SafeConfiguration cfg new SafeConfiguration(); cfg.setTemplateLoader(new SafeFileTemplateLoader(new java.io.File(/opt/app/templates))); configurer.setConfiguration(cfg); return configurer; } }注意点setTemplateLoaderPath和setConfiguration同时用时后者优先级更高实际以SafeConfiguration里的 loader 为准。如果模板在 classpath 下可以用ClassTemplateLoader但路径穿越风险较小仍建议保留SafeMemberAccessPolicy。改完后要跑一遍现有模板确认没有用到被禁的方法。4. 避坑与排查改造过程中最容易翻车的几个点4.1 现象页面报 The ?api built-in is disabled原因模板里某处用了${obj?api.someMethod()}改造后?api被禁用。解决全局搜索模板文件里的?api改成直接访问属性或提前在数据模型里处理好。如果确实需要暴露原始对象用?api的白名单替代方案比如在 Java 侧封装一个安全的TemplateMethodModelEx。4.2 现象${user.name}突然不生效了原因SafeMemberAccessPolicy只允许get和is开头的方法如果User类里是name()这种 record 风格的方法会被拒绝。解决在策略里补充对 record 访问器的判断或者把name()改成getName()。常见做法是加一条如果方法所在类是 record允许其访问器。4.3 现象模板加载报 Template path out of base directory原因SafeFileTemplateLoader的路径校验太严格模板名里带了./或//导致规范化后路径不匹配。解决在findTemplateSource里先对name做一次replace(//, /)和replace(./, )再拼路径。或者用Paths.get(baseDir).resolve(name).normalize()来判断。4.4 现象升级到 2.3.32 后配置冲突原因新版本 Freemarker 的Configuration默认值已经收紧再叠加自定义策略可能导致某些方法双重拒绝。解决如果决定升级版本先移除SafeMemberAccessPolicy只保留setAPIBuiltinEnabled(false)和setNewBuiltinClassResolver跑通后再逐步加回。4.5 现象日志里大量 TemplateExceptionHandler 警告原因RETHROW_HANDLER会把异常直接抛给调用方如果上层没捕获日志会刷屏。解决在 Controller 层加统一异常处理把TemplateException转成友好的错误页。或者临时用TemplateExceptionHandler.DEBUG_HANDLER观察但生产环境务必用RETHROW_HANDLER。注意改造后一定要用安全扫描工具复测不要只靠单元测试。5. 二次开发与验证怎么确认你的 Configuration 真的安全了改造完成后验证比写代码更重要。我一般会从三个层面做检查单元测试、集成测试、手工攻击模拟。5.1 写一个攻击模拟测试类import freemarker.template.Template; import freemarker.template.TemplateException; import org.junit.jupiter.api.Test; import java.io.StringWriter; import java.util.HashMap; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertThrows; public class SafeConfigurationTest { Test public void testApiBuiltinDisabled() throws Exception { SafeConfiguration cfg new SafeConfiguration(); Template tpl new Template(test, ${obj?api}, cfg); MapString, Object model new HashMap(); model.put(obj, new Object()); assertThrows(TemplateException.class, () - tpl.process(model, new StringWriter())); } Test public void testNewBuiltinDisabled() throws Exception { SafeConfiguration cfg new SafeConfiguration(); Template tpl new Template(test, ${java.lang.Runtime?new()}, cfg); assertThrows(TemplateException.class, () - tpl.process(new HashMap(), new StringWriter())); } Test public void testGetClassDenied() throws Exception { SafeConfiguration cfg new SafeConfiguration(); Template tpl new Template(test, ${obj.getClass()}, cfg); MapString, Object model new HashMap(); model.put(obj, new Object()); assertThrows(TemplateException.class, () - tpl.process(model, new StringWriter())); } }这个测试类覆盖了三个主要攻击面?api、?new、getClass()。每个用例都期望抛TemplateException如果没抛就说明配置有漏洞。跑通后可以再加?eval和路径穿越的用例。5.2 用真实模板做回归攻击测试通过不代表业务模板能正常渲染。把项目里所有.ftl文件跑一遍重点看有没有用到?api、?new、?eval有就改。有没有调用非 getter 方法比如${list.size()}这种size()不是 getter会被拒绝。改成${list?size}。有没有访问字段比如${user.name}如果name是 public 字段而不是 getter会被setExposeFields(false)拦住。改成 getter 或临时放开。5.3 改造清单与优先级改造项优先级影响面回滚难度禁用?api高低低设置SAFER_RESOLVER高低低自定义MemberAccessPolicy中中中路径安全TemplateLoader中低低替换 Spring 配置高高中建议按优先级从高到低做每做一项跑一次回归。如果项目模板量大可以先在测试环境开DEBUG_HANDLER观察哪些模板触发了拒绝再逐个改。5.4 一个容易忽略的点缓存Freemarker 的Configuration有模板缓存改完配置后如果没清缓存可能还在用旧的模板对象。常见做法是在SafeConfiguration构造函数里设置setCacheStorage(new SoftCacheStorage())或setTemplateUpdateDelayMilliseconds(0)强制刷新。生产环境建议保留缓存但改造后重启一次应用。从那以后我每次改 Freemarker 配置都强制走一遍「攻击测试 → 业务回归 → 扫描复测」三步不再只靠眼睛看。希望帮到你。本文还有配套的精品资源点击获取