简介这份资源面向仍在使用老旧 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 SetString 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 groupIdorg.freemarker/groupId artifactIdfreemarker/artifactId version2.3.31/version /dependency dependency groupIdjunit/groupId artifactIdjunit/artifactId version4.13.2/version scopetest/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-8setTemplateExceptionHandler设为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. 二次开发与改造把安全配置做成可复用的 Starter5.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 SetString 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()调用 → 确认白名单 → 跑攻击载荷 → 清缓存重启」的流程少一步都可能留口子。希望帮到你。本文还有配套的精品资源点击获取