简介这份资源面向仍在使用老旧 JEECG Boot 版本、无法平滑升级的 Java 开发者提供一种低成本修复 Freemarker 模板注入漏洞的应急方案。其核心做法是直接重写 freemarker 的 Configuration 类在实例化方法中默认加入 this.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER)从而在不改动整体框架的前提下收紧模板解析策略降低远程代码执行风险。压缩包内共 1 个文件为单个 java 源码文件包体约 13KB体量轻便便于直接放入项目对应目录替换使用。目前已有 1339 人学习下载说明该思路在存量系统安全加固场景中具有一定参考价值。对于需要快速封堵漏洞、又受限于版本升级成本的团队这份源码可作为排错与改造的起点帮助理解 SAFER_RESOLVER 的接入位置与生效方式并据此评估自身模板调用链是否存在同类隐患。1. 重写 Configuration 的起因一次模板注入排查给我的教训去年做代码审计时碰到一个案例某内部系统用了 Freemarker 2.3.31 渲染用户提交的模板片段表面上看只是让运营配置活动页文案实际上攻击者可以构造${freemarker.template.utility.Execute?new()(id)}这类 payload 直接执行系统命令。当时第一反应是升级版本但项目依赖链锁死升级会连带影响十几个模块。于是我选择了一条更可控的路重写Configuration类从加载源头掐断模板注入的可能。这篇内容适合正在用 Freemarker 做模板渲染、又没法立刻升级框架的 Java 开发者我会把重写的判断依据、关键代码、踩过的坑全部摊开讲新手能照着改熟手能看到每一步为什么这么设计。Freemarker 模板注入的本质是?new()内置函数允许实例化任意实现了TemplateModel的类配合Execute、ObjectConstructor等工具类就能突破沙箱。2.3.31 这个版本默认没有开启SAFER_RESOLVERConfiguration的newBuiltinClassResolver属性是UNRESTRICTED_RESOLVER等于把危险类的入口敞开着。很多人以为设置setAPIBuiltinEnabled(false)就安全了其实那只堵了?api一个口子?new()照样能走。我重写的核心思路就一句话在Configuration初始化阶段就把类解析器换成白名单模式同时把TemplateClassResolver的resolve方法改成只放行明确需要的类其余一律抛异常。2. 重写前的判断依据四个维度决定要不要动 Configuration2.1 版本升级和重写 Configuration 的取舍先说要命的现实问题Freemarker 在 2.3.29 之后才引入SAFER_RESOLVER2.3.31 虽然带了TemplateClassResolver.SAFER_RESOLVER这个常量但默认值仍然是UNRESTRICTED_RESOLVER。也就是说光靠升级小版本不解决问题必须显式配置。那为什么不直接升到 2.3.34 或更高因为很多老项目的Configuration是单例被 Spring 的FreeMarkerConfigurer托管升级后freemarker.template.Configuration的构造函数签名和setSettings行为有细微变化容易在启动阶段抛IncompatibleImprovements相关的异常。我一般会这么比如果项目里 Freemarker 只用于邮件模板、代码生成这类可控场景升级加配置就够了如果模板内容部分来自用户输入或外部系统重写Configuration做白名单更稳因为你能控制类加载的每一个入口。维度升级到 2.3.34重写 Configuration改动范围改 pom 版本 加配置新增一个子类替换 Bean 定义对依赖链影响可能连带升级其他库零依赖变更防护粒度全局 SAFER_RESOLVER可按模板来源分级回滚成本降版本可能引发兼容问题删掉子类即可适用场景新项目、依赖干净老系统、依赖锁死2.2 模板注入的真实攻击面在哪很多人只盯着?new()其实 Freemarker 的注入面比想象中宽。?api能拿到JavaBean的getClass()再反射调用?eval能动态执行字符串表达式#assign配合?interpret也能跑出意外代码。我在重写时把Configuration的setNewBuiltinClassResolver和setAPIBuiltinEnabled两个开关都收紧了但真正兜底的是重写TemplateClassResolver.resolve方法。下面这段是我实际用的白名单逻辑只放行java.util.ArrayList、java.util.HashMap这类纯数据结构其余全部拒绝。public class SafeTemplateClassResolver implements TemplateClassResolver { // 只允许实例化这些无副作用的类 private static final SetString WHITE_LIST new HashSet(Arrays.asList( java.util.ArrayList, java.util.HashMap, java.util.LinkedList )); Override public Class? resolve(Environment env, String className, String templateName) throws TemplateException { if (!WHITE_LIST.contains(className)) { // 直接抛异常让模板渲染失败而不是静默放行 throw new TemplateException( Class className is not allowed in template: templateName, env); } try { return ClassUtil.forName(className); } catch (ClassNotFoundException e) { throw new TemplateException(Class not found: className, e, env); } } }这段代码的关键在resolve的第三个参数templateName它记录了是哪个模板触发的类解析方便排查是哪个页面被注入了。Environment参数可以用来拿当前模板的配置但别在里面做复杂逻辑否则每次?new()都走一遍反射性能会掉。白名单里的类要满足两个条件一是无参构造函数公开二是没有能触发文件读写或命令执行的方法。ArrayList和HashMap是安全的但别把java.io.File加进去哪怕只是实例化后续配合?api也能搞出事情。2.3 重写 Configuration 时怎么保证不破坏原有渲染重写Configuration最怕的是把正常模板搞挂。我的做法是继承org.freemarker.template.Configuration在构造函数里先调super()然后立刻设置setNewBuiltinClassResolver(new SafeTemplateClassResolver())和setAPIBuiltinEnabled(false)。注意setAPIBuiltinEnabled必须在setSettings之前调用否则会被覆盖。另外setTemplateExceptionHandler要改成TemplateExceptionHandler.RETHROW_HANDLER这样注入尝试会直接抛异常而不是被吞掉方便日志告警。下面是我重写后的构造函数片段参数incompatibleImprovements传Configuration.VERSION_2_3_31保证和原版本行为一致。public class SafeConfiguration extends Configuration { public SafeConfiguration() { super(VERSION_2_3_31); // 关闭 ?api 内置函数 setAPIBuiltinEnabled(false); // 替换类解析器为白名单模式 setNewBuiltinClassResolver(new SafeTemplateClassResolver()); // 注入尝试直接抛异常不静默处理 setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); // 禁止访问危险的内置变量 setSetting(auto_import, ); } }setSetting(auto_import, )这行是为了防止模板通过auto_import引入外部模板文件有些攻击链会利用这个机制加载恶意模板。VERSION_2_3_31这个常量决定了 Freemarker 的内部行为比如?api的默认状态、数字格式化规则传错会导致渲染结果和原来不一致。我踩过一次坑传了VERSION_2_3_0结果日期格式化从yyyy-MM-dd变成了yyyy-MM-dd HH:mm:ss页面全乱。所以重写时一定要和原项目的incompatibleImprovements对齐不确定就打印原Configuration的getIncompatibleImprovements()看一眼。3. 重写 Configuration 的完整步骤从继承到替换 Bean3.1 第一步定位原 Configuration 的注入点在动手重写之前先搞清楚项目里Configuration是怎么被创建的。常见的有三种Spring Boot 的FreeMarkerConfigurer自动装配、手动new Configuration()、以及通过freemarker.template.Configuration的静态getDefaultConfiguration()。前两种好办替换 Bean 或改new的类名就行第三种最麻烦因为getDefaultConfiguration()返回的是全局单例你没法直接替换。我的处理方式是写一个ConfigurationFactory在应用启动时用反射把Configuration的defaultConfiguration字段替换成SafeConfiguration实例。下面这段代码是反射替换的核心逻辑注意setAccessible(true)在 JDK 9 以上需要加--add-opens参数否则会抛InaccessibleObjectException。public class ConfigurationFactory { public static void replaceDefaultConfiguration() throws Exception { Field field Configuration.class.getDeclaredField(defaultConfiguration); field.setAccessible(true); // 先取出原实例保留其 settings Configuration original (Configuration) field.get(null); SafeConfiguration safe new SafeConfiguration(); // 把原配置的编码、模板路径等复制过来 safe.setDefaultEncoding(original.getDefaultEncoding()); safe.setDirectoryForTemplateLoading(original.getDirectoryForTemplateLoading()); field.set(null, safe); } }getDefaultEncoding()和getDirectoryForTemplateLoading()这两个属性必须复制否则模板加载路径会变导致FileNotFoundException。如果原Configuration还设置了setTemplateLoader也要一并复制但TemplateLoader本身可能带注入风险复制前先检查它的load方法有没有做路径校验。我一般会在替换后加一行日志打印safe.getNewBuiltinClassResolver()的类名确认是SafeTemplateClassResolver而不是UNRESTRICTED_RESOLVER。3.2 第二步白名单类的筛选和测试白名单不是拍脑袋定的。我的筛选流程是先跑一遍项目里所有模板用-Dfreemarker.loggerDEBUG把?new()调用的类名全打出来然后逐个判断。大部分业务模板只会用到ArrayList、HashMap、Date这几个。Date要特别小心因为java.util.Date的getTime()本身无害但配合?api能拿到Class对象所以setAPIBuiltinEnabled(false)必须同时开。筛选完之后写单元测试用恶意 payload 跑一遍确认全部抛TemplateException。下面是我用的测试用例覆盖了Execute、ObjectConstructor、JythonRuntime三个最常见的利用类。Test public void testMaliciousPayload() { SafeConfiguration cfg new SafeConfiguration(); String[] payloads { ${freemarker.template.utility.Execute?new()(id)}, ${freemarker.template.utility.ObjectConstructor?new()(java.lang.ProcessBuilder,id)}, ${freemarker.template.utility.JythonRuntime?new()} }; for (String payload : payloads) { try { new Template(test, new StringReader(payload), cfg).process(null, new StringWriter()); fail(Payload should be blocked: payload); } catch (Exception e) { // 预期抛异常说明白名单生效 assertTrue(e.getMessage().contains(not allowed)); } } }Execute是直接执行命令的ObjectConstructor能构造任意对象JythonRuntime能跑 Python 代码这三个是 Freemarker 注入的经典利用链。测试时注意Template.process的第二个参数传null会触发InvalidReferenceException但不影响类解析器的拦截逻辑因为?new()在解析阶段就执行了。如果测试通过但线上还是被注入检查是不是有模板走了Configuration的getTemplate重载方法绕过了SafeConfiguration比如某些框架会缓存Template对象缓存里的Configuration引用还是旧的。3.3 第三步替换 Spring Bean 并验证Spring Boot 项目里FreeMarkerConfigurer会创建Configuration替换方式是自定义一个FreeMarkerConfigurationFactoryBean的子类覆盖createConfiguration()方法返回SafeConfiguration。如果是 XML 配置直接把org.springframework.ui.freemarker.FreeMarkerConfigurer的freemarkerSettings里加一条new_builtin_class_resolver指向你的类。但注意 Spring 的FreeMarkerConfigurer在afterPropertiesSet里会调setSettings如果你的SafeConfiguration构造函数里设了setNewBuiltinClassResolver而setSettings又把它覆盖回默认值就白干了。解决办法是在SafeConfiguration里重写setSettings在调super.setSettings之后重新设一遍白名单解析器。Override public void setSettings(Properties settings) { super.setSettings(settings); // 防止 setSettings 覆盖安全配置 setNewBuiltinClassResolver(new SafeTemplateClassResolver()); setAPIBuiltinEnabled(false); }验证阶段我一般分三步先跑一遍全量模板渲染的回归测试确认页面输出和改之前一致再用curl发几个恶意 payload 到线上灰度环境看返回是不是 500 且日志里有not allowed最后用grep -r ?new() templates/扫一遍模板目录把业务里不必要的?new()调用全删掉。有一次我发现一个老模板里用了?new()创建SimpleDateFormat白名单里没加java.text.SimpleDateFormat结果页面直接挂了。后来我把SimpleDateFormat加进白名单但加了注释说明它只用于格式化不能传用户可控的参数。4. 避坑记录重写 Configuration 时最容易翻车的五个点4.1 坑一setSettings 覆盖安全配置现象是重写完Configuration后用 payload 测试仍然能执行命令。原因在于 Spring 的FreeMarkerConfigurer在初始化时会调setSettings而setSettings内部会把new_builtin_class_resolver重置为UNRESTRICTED_RESOLVER。解决办法是像上面那样重写setSettings在super.setSettings之后重新设一遍白名单解析器。更稳妥的做法是直接重写setNewBuiltinClassResolver在里面判断如果传入的是UNRESTRICTED_RESOLVER就忽略强制用自己的SafeTemplateClassResolver。4.2 坑二反射替换 defaultConfiguration 被 JDK 模块系统拦截现象是启动时抛InaccessibleObjectException: Unable to make field private static freemarker.template.Configuration freemarker.template.Configuration.defaultConfiguration accessible。原因是 JDK 9 以上对反射访问私有字段做了限制。解决办法是在启动参数里加--add-opens java.base/freemarker.templateALL-UNNAMED但更推荐的做法是别用反射改成在应用启动的最早阶段比如main方法第一行就new SafeConfiguration()并调Configuration.setDefaultConfiguration(safe)这个方法在 2.3.31 里是公开的不用反射。4.3 坑三白名单漏了业务必需的类导致页面 500现象是重写后部分页面报TemplateException: Class java.text.SimpleDateFormat is not allowed。原因是业务模板里用?new()创建了格式化类。解决办法是先把日志级别调到DEBUG跑一遍全量页面把所有?new()的类名收集起来逐个评估后加入白名单。加的时候要写注释说明用途比如// 仅用于日期格式化参数不可控。千万别图省事直接把java.lang.Runtime加进去那等于没防。4.4 坑四TemplateExceptionHandler 设成 IGNORE_HANDLER 导致注入被吞现象是 payload 测试时页面正常返回没有报错但日志里也没有拦截记录。原因是TemplateExceptionHandler被设成了IGNORE_HANDLER异常被静默处理了。解决办法是改成RETHROW_HANDLER让异常直接抛到上层同时配一个全局异常处理器记录日志。如果业务上不能因为模板异常就挂页面可以改成DEBUG_HANDLER它会打印异常但继续渲染至少你能在日志里看到注入尝试。4.5 坑五只防了 ?new() 没防 ?api 和 ?eval现象是?new()被拦了但攻击者用${.getClass().forName(java.lang.Runtime)}配合?api仍然能拿到Runtime类。原因是?api内置函数没关。解决办法是setAPIBuiltinEnabled(false)这个开关在 2.3.31 里是有效的。另外?eval和?interpret也要注意它们能动态执行字符串表达式如果模板内容部分来自用户输入最好在业务层做一次白名单过滤只允许字母、数字和少量符号。5. 重写后的验证与长期维护一个具体技巧收尾重写Configuration只是第一步真正难的是长期维护。我的习惯是在SafeTemplateClassResolver里加一个静态计数器每次拦截到非法类就AtomicLong.incrementAndGet()然后通过 JMX 或 Micrometer 暴露出去。这样线上一旦有注入尝试监控面板上能看到freemarker.blocked.count飙升比翻日志快得多。下面这段是计数器的实现注意用AtomicLong而不是LongAdder因为拦截频率不会太高AtomicLong足够且能保证顺序。public class SafeTemplateClassResolver implements TemplateClassResolver { private static final AtomicLong BLOCKED_COUNT new AtomicLong(0); public static long getBlockedCount() { return BLOCKED_COUNT.get(); } Override public Class? resolve(Environment env, String className, String templateName) throws TemplateException { if (!WHITE_LIST.contains(className)) { BLOCKED_COUNT.incrementAndGet(); // 记录模板名和类名方便溯源 env.getOut().flush(); throw new TemplateException( Blocked class: className in template: templateName, env); } // ... 白名单放行逻辑 } }这个计数器我接入了 Prometheus配了一条告警规则5 分钟内freemarker.blocked.count增长超过 10 就发通知。有一次凌晨收到告警查日志发现是某个内部系统在跑自动化测试payload 里带了?new()虽然被拦了但触发了计数。这件事让我意识到白名单不能太死测试环境可以放宽生产环境必须严格。后来我在SafeConfiguration里加了一个setStrictMode(boolean)方法测试环境传false时只记录不抛异常生产环境传true直接拦截。回过头看重写Configuration这件事最大的价值不是防住了某个具体 payload而是把模板渲染的安全边界从“依赖框架默认值”变成了“自己可控的白名单”。从那以后我每次接手老项目第一件事就是grep -r ?new()扫模板目录第二件事就是检查Configuration的newBuiltinClassResolver是不是UNRESTRICTED_RESOLVER。这两个动作花不了十分钟但能挡住大部分低级注入。希望帮到你。本文还有配套的精品资源点击获取