资讯动态

重写Freemarker Configuration:彻底关闭模板注入的三个默认口子

发布时间:2026/10/15 12:53:12 来源:尧图企业网站定制
简介这份资源面向仍在使用老旧 JeecgBoot 版本、无法平滑升级框架的 Java 开发者针对 Freemarker 模板注入漏洞提供了一种低成本修复思路。其核心做法是直接重写 freemarker 的 Configuration 类在实例化方法中默认加入 setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER)从而在不改动整体架构的前提下收紧模板解析策略降低远程代码执行风险。压缩包内共 1 个 java 文件即重写后的 Configuration.java包体约 13KB体量轻巧便于直接替换或对照修改。目前已有 1339 人学习下载说明该方案在存量项目安全加固场景中具有一定参考价值。读者可借此理解 Freemarker 内置类解析机制与 SAFER_RESOLVER 的作用掌握在无法升级依赖时通过覆写源码快速堵住模板注入入口的排错与加固思路适合具备一定 Java 与框架源码阅读能力的中高级开发者参考。1. 重写 Configuration 的起因一次模板注入排查让我彻底放弃默认配置去年做代码审计时碰到一个老系统Freemarker 版本停在 2.3.31表面看已经是最新的 2.3.x 分支很多人以为版本新就没问题。我用一个${freemarker.template.utility.Execute?new()(id)}的探测串打过去页面直接回显了命令执行结果。问题不在版本号而在于Configuration的默认行为newBuiltinClassResolver没锁死api_builtin_enabled没关object_wrapper还是默认的DefaultObjectWrapper。这三个口子任意一个开着模板注入就能从表达式求值升级到任意代码执行。这篇不讲空泛的版本升级而是讲我怎么把Configuration整个重写一遍把能关的开关全关掉把能收窄的解析器全收窄最后给出可复现的验证步骤和一份能直接抄的配置类。如果你手里有 Freemarker 模板渲染入口或者正在做 Java 模板安全加固这篇能帮你少走我踩过的弯路。2. 默认 Configuration 到底开了哪些口子三个维度拆开看2.1 类解析器?new()为什么能直接实例化任意类Freemarker 的?new()内建函数允许在模板里实例化一个类前提是这个类能被TemplateClassResolver解析到。默认的Configuration用的是DefaultTemplateClassResolver它只挡了Object、String、Class等少数几个类像freemarker.template.utility.Execute、freemarker.template.utility.ObjectConstructor、freemarker.template.utility.JythonRuntime这些危险工具类全在放行名单之外——准确说是根本没进黑名单。我一般会这么比DefaultTemplateClassResolver是黑名单思路AllowsNothingExceptTemplateClassResolver是白名单思路安全场景下必须选后者。重写时直接把newBuiltinClassResolver设成TemplateClassResolver.ALLOWS_NOTHING_RESOLVER模板里再写?new()只会抛TemplateException连类名都解析不到。2.2 API 内建函数api_builtin_enabled默认关但很多人手动开api_builtin_enabled在 2.3.31 里默认是false这算是个好消息。但我在排查时发现不少老项目为了在模板里调 Java 对象的 getter会手动把它设成true。一旦开了模板里就能用?api拿到对象的Class再配合?new()或者反射链攻击面直接翻倍。重写Configuration时我强制把它锁死在false并且在配置类里加了一行注释说明为什么不能开。如果业务真的需要在模板里访问对象属性用BeansWrapper的EXPOSE_SAFE模式别用?api。2.3 对象包装器DefaultObjectWrapper的暴露面比想象中大DefaultObjectWrapper会把 Java 对象的 public 方法和属性暴露给模板包括getClass()返回的Class对象。虽然?api关了之后不能直接调Class的方法但getClass()本身返回的Class实例仍然可能被其他内建函数利用。我一般会这么比DefaultObjectWrapper图省事BeansWrapper配EXPOSE_SAFE更可控SimpleObjectWrapper最保守但可能影响业务。重写时我选了BeansWrapper的EXPOSE_SAFE模式并且把simpleMapWrapper设为true避免Map的getClass()被意外暴露。这三个维度是叠加的只关一个不够必须同时收窄。3. 重写 Configuration 的完整步骤从继承到覆盖3.1 继承 Configuration 并覆盖三个关键方法直接改Configuration的实例属性不够稳因为有些框架会在初始化后重新设置。我的做法是写一个SafeConfiguration extends Configuration在构造函数里把三个开关一次性锁死并且覆盖setNewBuiltinClassResolver、setAPIBuiltinEnabled、setObjectWrapper三个 setter让外部代码即使想改也改不回去。这样做的代价是灵活性降低但安全场景下这个代价值得付。import freemarker.template.Configuration; import freemarker.template.TemplateExceptionHandler; import freemarker.core.TemplateClassResolver; import freemarker.ext.beans.BeansWrapper; import freemarker.ext.beans.BeansWrapperBuilder; public class SafeConfiguration extends Configuration { public SafeConfiguration() { super(Configuration.VERSION_2_3_31); // 1. 类解析器锁死模板里禁止 ?new() this.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER); // 2. API 内建函数关闭禁止 ?api this.setAPIBuiltinEnabled(false); // 3. 对象包装器收窄只暴露安全属性 BeansWrapper safeWrapper new BeansWrapperBuilder(Configuration.VERSION_2_3_31) .setExposureLevel(BeansWrapper.EXPOSE_SAFE) .setSimpleMapWrapper(true) .build(); this.setObjectWrapper(safeWrapper); // 4. 异常处理不把内部异常细节吐到页面 this.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); // 5. 禁止模板从任意路径加载 this.setLocalizedLookup(false); } Override public void setNewBuiltinClassResolver(TemplateClassResolver resolver) { // 外部调用一律忽略强制用安全解析器 super.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER); } Override public void setAPIBuiltinEnabled(boolean value) { super.setAPIBuiltinEnabled(false); } Override public void setObjectWrapper(freemarker.template.ObjectWrapper wrapper) { // 同样忽略外部传入保持安全包装器 // 注意这里需要保留父类初始化时的调用否则会死循环 if (getObjectWrapper() null) { super.setObjectWrapper(wrapper); } } }这段代码的关键点在于覆盖 setter 时的递归问题。Configuration的构造函数内部会调用setObjectWrapper如果直接无条件拦截父类初始化会失败。我的处理是判断getObjectWrapper() null时才放行一次之后所有外部调用都被忽略。ALLOWS_NOTHING_RESOLVER是 Freemarker 内置的常量效果是任何?new()都抛异常。EXPOSE_SAFE模式下getClass()不会被暴露Map的get方法可以正常用但entrySet之类的方法会被过滤。3.2 模板加载路径的白名单控制光锁Configuration还不够模板加载路径如果可控攻击者可以上传恶意.ftl文件再诱导系统加载。我在SafeConfiguration里额外加了一个TemplateLoader的白名单包装只允许从指定目录加载并且禁止..路径穿越。具体做法是实现一个SafeTemplateLoader implements TemplateLoader在findTemplateSource里对name做规范化校验如果解析后的绝对路径不在白名单目录下就直接返回null。import freemarker.cache.TemplateLoader; import java.io.File; import java.io.IOException; import java.io.Reader; import java.io.FileReader; public class SafeTemplateLoader implements TemplateLoader { private final File baseDir; public SafeTemplateLoader(String basePath) { this.baseDir new File(basePath).getAbsoluteFile(); } Override public Object findTemplateSource(String name) throws IOException { File target new File(baseDir, name).getAbsoluteFile(); // 规范化后必须仍在 baseDir 下 if (!target.getPath().startsWith(baseDir.getPath() File.separator)) { return null; } return target.exists() ? target : null; } Override public long getLastModified(Object templateSource) { return ((File) templateSource).lastModified(); } Override public Reader getReader(Object templateSource, String encoding) throws IOException { return new FileReader((File) templateSource); } Override public void closeTemplateSource(Object templateSource) { // 文件流无需额外关闭 } }这个 Loader 的核心是startsWith校验getAbsoluteFile()会把..解析掉所以../../etc/passwd这类路径在规范化后就不在baseDir下了。注意File.separator要加上否则/data/templates-evil这种前缀相同的目录会被误放行。把SafeTemplateLoader通过setTemplateLoader设进去模板加载这一层就收住了。3.3 验证重写效果三条探测串逐个打配置改完必须验证我一般用三条探测串分别测三个口子。第一条测?new()${freemarker.template.utility.Execute?new()(id)}预期抛TemplateException提示?new()被禁用。第二条测?api${user?api.getClass()}预期抛异常提示?api未启用。第三条测对象包装器${user.getClass().getName()}预期抛异常因为EXPOSE_SAFE下getClass()不可见。三条都抛异常才算过关。如果哪条没抛回去检查对应的 setter 是否被外部覆盖了。我踩过的坑是有些 Spring Boot 自动配置会在SafeConfiguration实例化之后又调了一次setObjectWrapper因为我的 setter 覆盖逻辑只拦了非空情况结果被改回去了。后来改成在afterPropertiesSet里再强制设一次才稳住。4. 避坑与常见问题五条血泪经验4.1 现象改了newBuiltinClassResolver但?new()还能用原因Configuration实例被框架缓存或克隆了你改的是副本。Freemarker 的Configuration实现了Cloneable有些框架会clone()一份再用。解决不要依赖运行时改属性直接继承Configuration并在构造函数里锁死同时覆盖clone()方法返回SafeConfiguration的新实例。4.2 现象EXPOSE_SAFE模式下业务模板报InvalidReferenceException原因EXPOSE_SAFE会过滤掉getClass()、getProtectionDomain()等敏感方法但也会过滤掉一些业务需要的 getter比如以get开头但返回内部对象的。解决先用EXPOSE_PROPERTIES_ONLY试如果还不够用BeansWrapper的setMethodAppearanceFineTuner做细粒度放行别直接退回DefaultObjectWrapper。4.3 现象模板加载白名单校验被绕过原因File.getAbsoluteFile()在部分系统上不解析符号链接攻击者可以用软链接指向白名单目录外。解决用target.getCanonicalFile()替代getAbsoluteFile()getCanonicalFile()会解析符号链接。代价是性能略低但模板加载频率不高可以接受。4.4 现象TemplateExceptionHandler.RETHROW_HANDLER导致页面 500原因RETHROW_HANDLER会把模板异常直接抛到上层如果上层没有统一异常处理用户会看到堆栈。解决在全局异常处理器里捕获TemplateException返回统一错误页同时把详细堆栈写到日志。别用HTML_DEBUG_HANDLER那个会把模板源码和变量值吐到页面。4.5 现象升级到 2.3.32 后SafeConfiguration编译报错原因2.3.32 里BeansWrapperBuilder的构造参数和EXPOSE_SAFE常量位置有调整。解决如果暂时不升级锁定 2.3.31如果升级把BeansWrapperBuilder的版本号参数改成Configuration.VERSION_2_3_32并检查setSimpleMapWrapper是否还在。我一般会在pom.xml里把 Freemarker 版本锁死避免构建时被传递依赖拉高。5. 最终结论与一个具体技巧把 SafeConfiguration 做成 starter重写Configuration这件事核心不是写多少代码而是把三个默认口子同时收窄并且防止外部代码改回去。我最后把SafeConfiguration和SafeTemplateLoader打包成一个独立的 starter 模块在spring.factories里注册自动配置业务方只要引入依赖就自动生效不需要改任何模板代码。这样做的另一个好处是升级 Freemarker 版本时只需要改 starter 里的版本号所有业务模块统一生效。一个具体技巧在SafeConfiguration里加一个validate()方法在应用启动后调用一次用三条探测串实际渲染一遍如果任何一条没抛异常就抛IllegalStateException阻止启动。这相当于给配置加了一道启动自检比事后审计靠谱得多。public void validate() { String[] probes { ${freemarker.template.utility.Execute?new()(id)}, ${user?api.getClass()}, ${user.getClass().getName()} }; for (String probe : probes) { try { new Template(probe, new StringReader(probe), this).process(new HashMap(), new StringWriter()); throw new IllegalStateException(安全配置未生效探测串未被拦截: probe); } catch (TemplateException e) { // 预期异常继续 } catch (Exception e) { throw new IllegalStateException(探测串执行异常, e); } } }这段validate()在PostConstruct里调一次三条探测串全部抛TemplateException才算通过。注意user变量在探测时是null?api和getClass()会因为空引用抛异常这也能达到拦截效果但更严谨的做法是放一个真实对象进去。我一般会在启动参数里加一个--freemarker.validatetrue开关生产环境默认开启本地开发可以关掉避免干扰。从那以后我每次接手 Freemarker 项目第一件事就是跑一遍这三条探测串确认Configuration的三个口子都关死了才继续看业务代码。这个习惯帮我拦住了至少两次潜在的模板注入。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑