首页 / 资讯中心 / 文章详情

Freemarker 2.3.31 升级踩坑:模板注入漏洞修复与 Configuration 安全配置

Freemarker 2.3.31 升级踩坑:模板注入漏洞修复与 Configuration 安全配置 ★ FEATURED ARTICLE
简介这份资源面向仍在使用老版本 jeecgboot、无法平滑升级的 Java 开发者针对 Freemarker 模板注入漏洞提供一种低成本修复思路直接重写 freemarker 的 Configuration 类在实例化方法中默认加入 SAFER_RESOLVER 类解析策略从而在不改动整体框架的前提下堵住注入风险。压缩包内共 1 个文件为单个 Java 源码文件包体约 13KB体量轻巧便于直接放入项目对应目录替换使用。目前已有 1339 人学习下载说明该方案在旧版本维护场景中具有一定参考价值。读者可从中获得一份可直接落地的漏洞修复代码理解 Freemarker 模板类解析机制与安全配置方式并据此排查自身项目中同类注入隐患适合需要快速加固老系统的中高级 Java 开发者参考。1. 模板渲染突然报错为什么升级到 Freemarker 2.3.31 后页面直接 500上周排查一个线上故障现象很典型某内容管理后台的模板渲染接口突然大面积返回 500日志里刷的是freemarker.core.InvalidReferenceException和TemplateException但模板文件一个字没改代码也没动过。追下去才发现是构建时把 Freemarker 从旧版本升到了 2.3.31而项目里那个自定义的Configuration子类还在用老一套的setClassForTemplateLoading加setObjectWrapper组合新版本对ObjectWrapper和TemplateClassResolver的默认行为收紧了模板里一个原本能跑的空值引用直接炸了。这类问题折腾人的地方在于报错信息指向模板行号但根因在Configuration的初始化逻辑里新手照着行号去改模板只会越改越乱。Freemarker 模板注入漏洞的修复本质上就是重写Configuration的装配方式把TemplateClassResolver、ObjectWrapper、TemplateExceptionHandler这几个口子按安全基线重新配一遍。下面按我实际踩过的几个报错场景拆开讲每个都给出能直接抄的配置和排查顺序。2. 模板注入漏洞的根因?new()和TemplateClassResolver到底放行了什么2.1 漏洞触发的三个必要条件Freemarker 模板注入SSTI能成立通常要同时满足三个条件模板内容可被外部影响、Configuration没有限制类解析、ObjectWrapper暴露了危险方法。很多项目只做了第一条的防护比如模板存数据库、后台可编辑却忽略了后两条结果攻击者在模板里写${freemarker.template.utility.Execute?new()(id)}就能直接执行系统命令。?new()这个内建函数是核心入口它依赖TemplateClassResolver决定「哪些类允许被实例化」。2.3.31 之前的默认解析器是UNRESTRICTED_RESOLVER基本不拦2.3.31 起默认改成了SAFER_RESOLVER会屏蔽ObjectConstructor、Execute、JythonRuntime等一批危险类。但如果你在代码里显式setNewBuiltinClassResolver(Configuration.UNRESTRICTED_RESOLVER)等于把新版本的默认防护又关掉了这是最常见的翻车点。2.2 重写Configuration的安全基线配置我一般不在业务代码里零散地set一堆属性而是继承Configuration做一个统一入口把安全相关的开关一次性收口。下面这份是经过几个项目验证的基线import freemarker.cache.ClassTemplateLoader; import freemarker.cache.MultiTemplateLoader; import freemarker.cache.TemplateLoader; import freemarker.core.TemplateClassResolver; import freemarker.template.Configuration; import freemarker.template.DefaultObjectWrapperBuilder; import freemarker.template.TemplateExceptionHandler; public class SafeFreemarkerConfig extends Configuration { public SafeFreemarkerConfig() { super(Configuration.VERSION_2_3_31); // 1. 禁止 ?new() 实例化任意类只允许白名单内的安全类 this.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER); // 2. 收紧对象包装器禁止暴露 getClass() 等反射入口 DefaultObjectWrapperBuilder owb new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_31); owb.setExposeFields(false); owb.setForceLegacyNonListCollections(false); this.setObjectWrapper(owb.build()); // 3. 模板异常统一转成运行时异常避免把堆栈细节渲染到页面 this.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); // 4. 关闭 API 内建函数堵住 ?api 绕过包装器直接拿 Java 对象的路径 this.setAPIBuiltinEnabled(false); // 5. 模板加载走 classpath禁止从任意文件系统路径加载 TemplateLoader loader new ClassTemplateLoader(this.getClass(), /templates); this.setTemplateLoader(new MultiTemplateLoader(new TemplateLoader[]{loader})); // 6. 输出编码固定避免模板内容被错误解码后二次注入 this.setDefaultEncoding(UTF-8); this.setOutputEncoding(UTF-8); } }逐条说下为什么这么配。SAFER_RESOLVER是 2.3.31 的安全默认值它内部维护了一个黑名单Execute、ObjectConstructor、JythonRuntime都会被拒绝实例化这是堵?new()的第一道闸。setAPIBuiltinEnabled(false)很多人会漏?api内建函数能绕过ObjectWrapper直接拿到对象的 Java API等于给攻击者开了后门必须关。RETHROW_HANDLER的作用是把模板异常原样抛出由上层统一处理而不是让 Freemarker 自己拼一段带堆栈的 HTML 返回给浏览器——后者在调试期方便上线就是信息泄露。DefaultObjectWrapperBuilder里setExposeFields(false)是防止模板通过${obj.someField}直接读私有字段setForceLegacyNonListCollections(false)则是让集合类型按标准List/Map语义处理减少包装器行为差异带来的意外暴露。提示如果你的项目还在用Configuration.VERSION_2_3_30及以下SAFER_RESOLVER的行为和 2.3.31 不完全一致升级时务必把incompatibleImprovements一起改到VERSION_2_3_31否则部分安全默认值不会生效。2.3 验证配置是否真的生效配完不能只看代码得实际打一发。写个最小验证模板内容就一行${freemarker.template.utility.Execute?new()(whoami)}用你的SafeFreemarkerConfig去getTemplate再process预期结果是抛TemplateException提示类不被允许实例化。如果它正常输出了命令结果说明TemplateClassResolver没生效大概率是某处又调了setNewBuiltinClassResolver覆盖回去或者incompatibleImprovements没设对。import freemarker.template.Template; import java.io.StringWriter; import java.util.HashMap; public class InjectionSmokeTest { public static void main(String[] args) throws Exception { SafeFreemarkerConfig cfg new SafeFreemarkerConfig(); // 从字符串加载一个恶意模板做冒烟验证 Template t new Template(evil, ${freemarker.template.utility.Execute?new()(whoami)}, cfg); StringWriter out new StringWriter(); try { t.process(new HashMap(), out); System.out.println(FAIL: 注入未拦截输出 out); } catch (Exception e) { System.out.println(PASS: 已拦截 - e.getMessage()); } } }这段冒烟测试建议进 CI每次改Configuration相关代码都跑一遍。Template构造函数直接吃字符串不依赖模板文件适合做单元测试process的第二个参数是数据模型这里传空HashMap即可因为恶意表达式不依赖外部数据。3. 升级后InvalidReferenceException刷屏空值处理和ObjectWrapper的兼容坑3.1 报错为什么在升级后才出现InvalidReferenceException的典型信息是The following has evaluated to null or missing指向模板里某个${user.name}之类的表达式。老版本里如果ObjectWrapper是DefaultObjectWrapper且incompatibleImprovements较低某些 null 场景会被静默处理成空字符串2.3.31 把incompatibleImprovements拉到新值后null 引用默认直接抛异常于是原本「能跑但结果不对」的模板全暴露了。这不是 bug是行为收紧。正确的做法不是把异常处理器改回RETHROW_HANDLER之外的宽松模式而是在模板层显式处理空值同时在Configuration层统一配置默认值策略。3.2 用setClassicCompatible之外的正当方式兜底有人图省事会setClassicCompatible(true)这等于回到老版本的宽松语义安全性和可维护性都倒退不推荐。我一般用两个手段组合模板里用!默认值操作符配置层用setLogTemplateExceptions加自定义TemplateExceptionHandler做可观测。// 配置层记录模板异常但不吞掉方便定位是哪个模板哪个变量 this.setTemplateExceptionHandler((te, env, out) - { // 打印模板名和出错行便于快速定位 System.err.println(Template error in env.getTemplate().getName() at line te.getCause().getMessage()); throw te; // 继续抛出由全局异常处理接管 }); this.setLogTemplateExceptions(true);模板侧则统一改成${user.name!}或${user.name!未知}!后面跟默认值null 时不再抛异常。批量改模板可以用脚本扫一遍${xxx}模式但要注意别误伤${xxx?string}这类已经带内建函数的表达式。3.3ObjectWrapper换实现后的类型不匹配另一个高频报错是Cant convert ... to ...出现在你把DefaultObjectWrapper换成BeansWrapper或自定义包装器之后。原因是不同包装器对Map、List、Date的默认适配策略不同模板里${createTime?string(yyyy-MM-dd)}在旧包装器下能识别java.util.Date新包装器可能把它当普通对象?string就报类型错误。排查顺序是先看报错里的实际类型freemarker.core.NonStringException会带上Expected ... but was ...再对照你ObjectWrapper的setExposeFields、setSimpleMapWrapper等开关。我一般保持DefaultObjectWrapperBuilder不动只调incompatibleImprovements这样类型适配行为和官方默认一致减少意外。注意setSimpleMapWrapper(true)会让Map的 key 直接当属性访问方便但会绕过部分包装器检查安全敏感场景建议保持默认false。4. 避坑清单重写Configuration时最容易翻车的五个点4.1 坑一子类构造里调super()顺序错安全配置被覆盖现象明明在子类构造函数里设了SAFER_RESOLVER冒烟测试还是能执行命令。原因父类Configuration的某个set方法在super()之后又被业务代码里的另一个配置类调了一遍把值改回去了。解决把所有set收口到唯一一个SafeFreemarkerConfig业务侧只new它不再二次set用grep -r setNewBuiltinClassResolver全局搜一遍确认没有第二处。4.2 坑二incompatibleImprovements没跟着版本走现象升级了 jar 包但Configuration构造里还写着VERSION_2_3_23。原因incompatibleImprovements决定了一大批默认行为不跟着改新版本的安全默认值不会启用。解决构造参数和setIncompatibleImprovements统一用Configuration.VERSION_2_3_31并在升级记录里单独列一条。4.3 坑三模板缓存把旧编译结果留在内存现象改完配置重启部分请求还是走旧逻辑。原因setTemplateUpdateDelayMilliseconds设得过大或用了自定义CacheStorage没清。解决开发环境设setTemplateUpdateDelayMilliseconds(0)生产环境确认缓存实现会在配置变更后失效排查时先clearTemplateCache()再复现。4.4 坑四?api关了但?new白名单漏了自定义类现象SAFER_RESOLVER生效了但攻击者仍能实例化项目里某个工具类。原因SAFER_RESOLVER只拦已知危险类项目自定义类不在黑名单里。解决如果确实需要?new用setNewBuiltinClassResolver传自定义TemplateClassResolver实现只放行明确需要的类其余一律拒绝。4.5 坑五异常处理器把堆栈渲染进页面现象线上 500 页面直接显示freemarker.core.开头的完整堆栈和模板路径。原因用了TemplateExceptionHandler.HTML_DEBUG_HANDLER或DEBUG_HANDLER。解决生产环境一律RETHROW_HANDLER调试信息只进日志不进响应体。5. 快速定位这类问题的排查路径从报错行号反推到Configuration排查 Freemarker 相关故障我的固定顺序是「先看异常类型再看模板行最后回配置」。异常类型决定方向InvalidReferenceException往空值和ObjectWrapper查TemplateException带「class not allowed」往TemplateClassResolver查NonStringException往包装器类型适配查。模板行号只是定位到表达式真正的开关在Configuration的初始化代码里。下面这张对照表是我贴在工位上的速查版报错关键词对根因和第一动作报错关键词最可能根因第一动作evaluated to null or missing空值未兜底 /incompatibleImprovements收紧模板加!默认值确认版本号class not allowed/Execute被拒TemplateClassResolver生效正常或白名单误配确认是否预期检查自定义 resolverCant convert ... to ...ObjectWrapper换实现后类型不匹配对比包装器开关回退到DefaultObjectWrapperBuilderTemplateNotFoundException模板加载路径变了检查TemplateLoader根路径和MultiTemplateLoader顺序页面出现完整堆栈异常处理器用了 DEBUG 模式改RETHROW_HANDLER堆栈只进日志验证手段上我习惯做三层单元测试跑注入冒烟第 2.3 节那段集成测试跑一遍核心模板渲染上线前用curl打一个带边界数据的接口看返回是否含异常关键字。三层里任何一层挂了都先回Configuration看set调用链而不是去改模板——十次里有八次根因在配置装配模板只是背锅的。从那以后我每次动 Freemarker 版本或Configuration相关代码都强制先跑一遍注入冒烟测试再提交宁可多花五分钟也不想再半夜被InvalidReferenceException叫醒。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站