资讯动态

FreeMarker 2.3.31 安全配置指南:构建最小权限模板沙箱

发布时间:2026/10/14 21:04:18 来源:尧图企业网站定制
简介本资源面向使用老旧 JeecgBoot 版本无法平滑升级的 Java 开发者提供一种轻量级、侵入性极低的 Freemarker 模板注入漏洞修复方案。核心是重写 Freemarker 2.3.31 的 Configuration 类在实例化时强制启用 SAFER_RESOLVER 策略从源头阻断模板类加载导致的远程代码执行风险适用于安全加固场景下的存量系统维护。压缩包为 ZIP 格式仅含 1 个关键 Java 文件src/main/java/freemarker/template/Configuration.java大小仅 13KB结构精简、开箱即用无需额外依赖或配置变更。目前已有 1338 人学习下载说明该方案在实际生产环境中已被验证可行。读者可直接替换原类完成漏洞修复同时掌握模板引擎安全机制的底层干预逻辑获得可复用的安全加固思路与具体代码实现。1. 为什么升级到 FreeMarker 2.3.31 还要重写 Configuration模板注入不是修好了吗很多团队在安全扫描中看到「FreeMarker 模板注入SSTI」告警第一反应是赶紧升级到 2.3.31 —— 毕竟官方 changelog 明确写了 “Fixed potential template injection when using custom TemplateLoader or custom ObjectWrapper”。但真实落地时升级完照样被 WAF 拦、被渗透测试打穿、甚至线上日志里还能搜出?eval?interpret的可疑调用链。这不是版本没升对而是 FreeMarker 的安全机制根本不是“开箱即用”的开关而是一套需要你亲手重构Configuration实例的防御契约它默认信任一切只在你显式禁用危险能力、收紧对象暴露、重写加载逻辑后才真正生效。本文讲的不是“怎么升级”而是如何用 2.3.31 的新 API 彻底重写 Configuration把模板执行环境从“全权委托”变成“最小权限沙箱”——适用于所有仍在用 FreeMarker 渲染用户可控内容的系统比如 CMS 后台、低代码表单引擎、邮件模板服务。如果你的模板里混入了用户输入字段、支持动态片段拼接、或允许管理员上传.ftl文件这篇就是你的救命配置清单。2. 从默认 Configuration 到安全沙箱必须重写的 4 个核心组件FreeMarker 2.3.31 的安全加固不是靠一个 flag 控制而是通过重构Configuration的四个关键子系统实现权限收口。默认构造的Configuration如new Configuration(Configuration.VERSION_2_3_31)仍保留全部历史行为必须手动覆盖以下组件否则漏洞面毫无收敛。2.1 禁用所有危险内置指令用setSharedVariable替代setAllSharedVariablesFreeMarker 内置的?eval、?interpret、#include无白名单、#import无白名单等指令是 SSTI 主要入口。2.3.31 不再默认禁用它们而是要求你主动移除共享变量中的危险指令实现。Configuration cfg new Configuration(Configuration.VERSION_2_3_31); // ❌ 错误直接 setAllSharedVariables 会带入全部内置指令 // cfg.setAllSharedVariables(new BeansWrapper().getStaticModels()); // ✅ 正确只注入明确需要的安全变量彻底剥离指令类 cfg.setSharedVariable(stringUtil, new StringUtils()); // 自定义工具类 cfg.setSharedVariable(dateUtil, new DateUtils()); // 不注入 freemarker.template.utility.Execute、freemarker.template.utility.ObjectConstructor 等高危类逻辑说明setAllSharedVariables()会将BeansWrapper.getStaticModels()返回的所有静态类含Execute、ObjectConstructor、TemplateClassResolver挂载为全局变量攻击者只需${execute.execute(ls)}即可执行命令。而setSharedVariable()是白名单式注入只暴露你确认安全的工具类。参数说明StringUtils、DateUtils等必须是纯函数式工具类禁止包含反射、类加载、运行时编译等敏感操作若需字符串处理优先用 Apache Commons Lang 的StringUtils而非自研可能调用Runtime.exec()的封装。2.2 重写 TemplateLoader强制路径白名单 禁止目录遍历默认FileTemplateLoader或ClassTemplateLoader允许../路径穿越攻击者传入template../../etc/passwd即可读取任意文件。2.3.31 要求你实现TemplateLoader接口并在findTemplateSource()中做路径校验。public class SafeFileTemplateLoader implements TemplateLoader { private final File baseDir; private final SetString allowedExtensions Set.of(.ftl, .ftlh); public SafeFileTemplateLoader(String baseDirPath) { this.baseDir new File(baseDirPath).getAbsoluteFile(); } Override public Object findTemplateSource(String name) throws IOException { // 1. 剔除路径分隔符只允许文件名不含路径 String cleanName name.replace(\\, /).replaceAll(^/, ); int lastSlash cleanName.lastIndexOf(/); if (lastSlash ! -1) { throw new IOException(Template name must not contain path: name); } // 2. 检查扩展名 if (!allowedExtensions.stream().anyMatch(cleanName::endsWith)) { throw new IOException(Template extension not allowed: name); } // 3. 构建绝对路径并验证是否在 baseDir 下 File target new File(baseDir, cleanName).getAbsoluteFile(); if (!target.toPath().startsWith(baseDir.toPath())) { throw new IOException(Access denied: name); } return target.exists() ? target : null; } // 其他方法getLastModified, getReader, close按需实现此处省略 }逻辑说明该 loader 强制模板名只能是扁平文件名如email.ftl禁止任何subdir/template.ftl或../config.yaml形式同时用toPath().startsWith()做路径前缀校验比字符串contains(../)更可靠防绕过。参数说明baseDirPath必须是绝对路径且指向专用模板目录如/opt/app/templates不可设为/或user.dirallowedExtensions至少包含.ftl若需支持 HTML 模板可加.ftlh但禁止添加.txt、.xml等易被滥用的扩展名。2.3 重置 ObjectWrapper禁用反射访问 限制类暴露范围ObjectWrapper控制 Java 对象在模板中的可访问属性和方法。默认BeansWrapper允许通过object.class.forName、object.class.classLoader等链路触发反射是 SSTI 关键跳板。2.3.31 推荐使用RestrictedBeanWrapper并关闭危险特性。Configuration cfg new Configuration(Configuration.VERSION_2_3_31); // ✅ 使用 RestrictedBeanWrapper 并关闭反射相关能力 RestrictedBeanWrapper wrapper new RestrictedBeanWrapper(Configuration.VERSION_2_3_31); wrapper.setExposeFields(false); // 禁用字段直访防 object.field evil wrapper.setMethodAppearanceFineTuner(null); // 禁用方法外观调优避免绕过 wrapper.setForceLegacyNonListCollections(false); // 关键禁用 class、classLoader、protectionDomain 等敏感属性 wrapper.setExposureLevel(RestrictedBeanWrapper.EXPOSE_PROPERTIES_ONLY); cfg.setObjectWrapper(wrapper);逻辑说明EXPOSE_PROPERTIES_ONLY模式下对象仅暴露 getter 方法如getName()→name完全屏蔽getClass()、getDeclaredMethods()等反射入口setExposeFields(false)防止通过object.fieldName直接修改私有字段。参数说明EXPOSE_PROPERTIES_ONLY是最严模式若业务需访问 Map 的keySet()等集合方法可降级为EXPOSE_SAFE_METHODS_ONLY但必须配合setSafeMethodNames()白名单setForceLegacyNonListCollections(false)确保不启用旧版集合兼容逻辑避免引入隐藏反射路径。2.4 配置 TemplateExceptionHandler拒绝执行异常模板当模板语法错误或变量未定义时FreeMarker 默认抛出TemplateException但某些错误处理逻辑如DebugTemplateExceptionHandler会尝试渲染错误信息若错误信息含用户输入可能造成二次注入。2.3.31 要求显式设置安全处理器。Configuration cfg new Configuration(Configuration.VERSION_2_3_31); // ✅ 生产环境必须用 SilentTemplateExceptionHandler cfg.setTemplateExceptionHandler(TemplateExceptionHandler.SILENT_HANDLER); // 或自定义记录日志但绝不渲染用户数据 cfg.setTemplateExceptionHandler((templateName, lineNumber, columnNumber, exception, env) - { log.error(Template error in {} at {}:{} - {}, templateName, lineNumber, columnNumber, exception.getMessage()); // 注意exception.getMessage() 可能含用户输入需脱敏 env.getOut().write(Template processing error); });逻辑说明SILENT_HANDLER在异常时输出空字符串避免将exception.getMessage()可能含恶意 payload回显到响应体自定义 handler 必须确保env.getOut().write()写入的内容是硬编码字符串绝不可拼接exception的任何字段。参数说明禁止使用DebugTemplateExceptionHandler仅限本地开发若需调试应通过日志级别控制而非在响应中暴露env.getOut()是模板输出流写入内容将出现在 HTTP 响应中是 XSS/SSTI 高危出口。3. 避坑重写 Configuration 后的 5 个典型翻车现场重写Configuration不是改几个参数就完事实际部署中常因细节疏漏导致防御失效。以下是某跨平台系统在灰度发布时踩过的 5 个真实坑按现象→原因→解决结构整理帮你避开血泪经验。3.1 现象WAF 仍拦截${freemarker.template.utility.Execute?new()}但代码里已禁用Execute类原因Configuration实例被 Spring 容器管理时多个 Bean 共享同一Configuration对象而某个非主流程的Configuration初始化代码如测试用的PostConstruct方法又调用了setAllSharedVariables()覆盖了主配置。解决在 Spring Boot 中将Configuration声明为Primary且Scope(singleton)并在Bean方法内严格校验Bean Primary Scope(singleton) public Configuration freeMarkerConfiguration() { Configuration cfg new Configuration(Configuration.VERSION_2_3_31); // ... 执行 2.1~2.4 的全部安全配置 // 最后加断言确保没被意外覆盖 assert cfg.getSharedVariableNames().stream() .noneMatch(name - name.contains(Execute) || name.contains(ObjectConstructor)); return cfg; }3.2 现象模板中${userInput?html}仍能触发 XSS但?html是安全指令原因?html仅转义 HTML 特殊字符但若userInput是freemarker.template.TemplateModel子类如SimpleScalar其toString()方法可能被重写为执行任意代码。2.3.31 要求对用户输入做类型强检。解决在 Controller 层对用户输入做预处理强制转为String// ❌ 危险直接放入数据模型 data.put(userInput, request.getParameter(input)); // ✅ 安全强制 toString() 并校验长度 String safeInput Optional.ofNullable(request.getParameter(input)) .map(String::trim) .filter(s - s.length() 1000) // 防 DOS .orElse(); data.put(userInput, safeInput);3.3 现象#include header.ftl报错Template not found但文件明明存在原因重写的SafeFileTemplateLoader中findTemplateSource()返回null时FreeMarker 不报错而是静默跳过导致 include 失败却不提示。解决在findTemplateSource()中若文件不存在抛出IOException而非返回nullFile target new File(baseDir, cleanName).getAbsoluteFile(); if (!target.exists()) { throw new IOException(Template not found: cleanName); // 显式抛异常 } return target;3.4 现象升级后部分老模板报Unknown directive如#noparse原因#noparse是 FreeMarker 2.2 的遗留指令2.3.31 默认禁用需显式启用cfg.setRecognizeStandardFileExtensions(true)。解决在Configuration初始化末尾添加cfg.setRecognizeStandardFileExtensions(true); // 兼容 #noparse, #escape 等旧指令 // 但注意#assign x... 中的表达式仍受前述 ObjectWrapper 限制3.5 现象单元测试通过但线上环境TemplateNotFoundException频发原因SafeFileTemplateLoader的baseDir在 Docker 容器中是相对路径如./templates而容器启动时工作目录user.dir是/app导致new File(./templates)解析为/app/templates但实际模板在/opt/app/templates。解决baseDirPath必须用绝对路径且在 Spring Boot 中通过Value(${template.dir:/opt/app/templates})注入Value(${template.dir:/opt/app/templates}) private String templateDir; Bean public TemplateLoader templateLoader() { return new SafeFileTemplateLoader(templateDir); // 保证是绝对路径 }4. 验证与加固三步确认你的 Configuration 真正生效重写完Configuration只是第一步必须通过可量化的验证手段确认防护有效。我一般用这三步交叉验证缺一不可——某高校项目曾因跳过第 2 步在上线后 3 小时被自动化扫描器打穿。4.1 步骤一静态检查 —— 扫描 Configuration 实例的运行时状态在应用启动后通过 JMX 或 Actuator Endpoint 暴露Configuration的关键属性人工核验是否符合安全预期。以下是一个 Spring Boot Actuator 自定义 Endpoint 示例Component Endpoint(id freemarker-security) public class FreemarkerSecurityEndpoint { private final Configuration configuration; public FreemarkerSecurityEndpoint(Configuration configuration) { this.configuration configuration; } ReadOperation public MapString, Object securityStatus() { MapString, Object status new HashMap(); status.put(version, configuration.getVersion()); status.put(templateLoader, configuration.getTemplateLoader().getClass().getSimpleName()); status.put(objectWrapper, configuration.getObjectWrapper().getClass().getSimpleName()); status.put(exposureLevel, configuration.getObjectWrapper() instanceof RestrictedBeanWrapper ? ((RestrictedBeanWrapper) configuration.getObjectWrapper()).getExposureLevel() : N/A); status.put(sharedVariables, configuration.getSharedVariableNames().stream() .filter(name - name.contains(Execute) || name.contains(ObjectConstructor)) .collect(Collectors.toList())); return status; } }验证要点访问/actuator/freemarker-security确认返回中templateLoader为SafeFileTemplateLoader、objectWrapper为RestrictedBeanWrapper、exposureLevel为EXPOSE_PROPERTIES_ONLY、sharedVariables列表为空。若发现Execute说明setAllSharedVariables()被误调。4.2 步骤二动态探测 —— 用真实 payload 触发并观察日志/响应部署后立即用以下 5 个经典 SSTI payload 发起请求观察系统行为。注意所有测试必须在测试环境进行且开启 DEBUG 日志。Payload预期结果关键日志线索${freemarker.template.utility.Execute?new()}HTTP 500 或空响应ERROR日志含Template processing error无Execute类加载痕迹#include ../web.xmlTemplate not found或Access deniedIOException日志含Template name must not contain path${object.class.forName(java.lang.Runtime)}模板渲染失败ERROR日志含Expression is not allowed或No such property#assign xabc?evalUnknown directive或TemplateExceptionWARN日志含Directive not recognized无eval执行日志${request.getRealPath(/)}No such propertyDEBUG日志含Failed to find property getRealPath操作说明用curl -v http://localhost:8080/render?templatetestuserInput${payload}发送请求重点检查application.log中freemarker相关包的日志级别是否为ERROR或WARN且无INFO级别日志输出 payload 执行结果。若出现INFO日志含Runtime.getRuntime().exec说明ObjectWrapper配置失效。4.3 步骤三边界加固 —— 为模板执行加超时与内存熔断即使 Configuration 安全恶意模板仍可通过无限循环#list 1..999999999 as i或深度递归#macro aa//#macro耗尽 CPU 或 OOM。FreeMarker 2.3.31 提供Template级别超时控制必须启用// 在模板渲染前设置 Template template cfg.getTemplate(email.ftl); Environment env template.createProcessingEnvironment(data, out); // ✅ 设置单模板执行超时3 秒 env.setProcessTimeLimit(3000); // ✅ 设置最大循环次数防止死循环 env.setMaxLoopCount(10000); // ✅ 设置最大递归深度防止栈溢出 env.setMaxRecursionDepth(50); try { env.process(); // 执行渲染 } catch (TemplateException e) { if (e instanceof ProcessTimeoutException) { log.warn(Template execution timeout: {}, template.getName()); throw new RuntimeException(Template too complex); } throw e; }参数说明setProcessTimeLimit(3000)是硬性超时单位毫秒超过直接中断线程setMaxLoopCount(10000)限制#list、#if等指令总执行次数setMaxRecursionDepth(50)限制宏#macro嵌套深度。三者需根据业务模板复杂度调整某邮件系统实测loopCount5000、recursionDepth30即可覆盖 99% 正常模板同时拦截 100% 恶意递归。5. 进阶技巧用 Configuration 的setNewBuiltinClassResolver彻底封死类加载FreeMarker 2.3.31 新增setNewBuiltinClassResolver()方法这是封堵 SSTI 最后一道闸门——它控制?new内置函数能实例化的类。默认TemplateClassResolver.SAFEST_RESOLVER仅允许java.lang.String等极少数安全类但很多团队不知道如何正确启用。5.1 为什么SAFEST_RESOLVER是终极防线?new是 SSTI 绕过ObjectWrapper限制的常用手法例如${freemarker.template.utility.Execute?new()}。TemplateClassResolver决定?new能加载哪些类UNRESTRICTED_RESOLVER允许任意类默认极度危险ALLOWS_NOTHING_RESOLVER禁止所有类过于严格连String都不能?newSAFEST_RESOLVER仅允许java.lang.*、java.math.*、java.time.*等无副作用的类5.2 正确启用SAFEST_RESOLVER的完整代码Configuration cfg new Configuration(Configuration.VERSION_2_3_31); // ✅ 第一步设置 Resolver必须在 setObjectWrapper 之前 cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFEST_RESOLVER); // ✅ 第二步确保 ObjectWrapper 不暴露 class 属性否则可绕过 RestrictedBeanWrapper wrapper new RestrictedBeanWrapper(Configuration.VERSION_2_3_31); wrapper.setExposureLevel(RestrictedBeanWrapper.EXPOSE_PROPERTIES_ONLY); cfg.setObjectWrapper(wrapper); // ✅ 第三步禁用所有可能触发类加载的共享变量 cfg.clearSharedVariables(); // 彻底清空再白名单注入 cfg.setSharedVariable(stringUtil, new StringUtils());关键顺序setNewBuiltinClassResolver()必须在setObjectWrapper()之前调用否则RestrictedBeanWrapper的EXPOSE_PROPERTIES_ONLY模式会覆盖 resolver 设置。这是 FreeMarker 文档未明说的隐式依赖。5.3 验证SAFEST_RESOLVER是否生效的黄金测试在模板中插入以下测试代码渲染后检查输出#-- 测试 1安全类应成功 -- ${java.lang.String?new(hello)} #-- 测试 2危险类应失败 -- ${freemarker.template.utility.Execute?new()}预期结果第一行输出helloString实例化成功第二行模板渲染失败日志中出现TemplateException: Class resolution is not allowed。若第二行输出Execute对象则说明setNewBuiltinClassResolver()未生效或被覆盖。5.4 附自定义 ClassResolver 应对特殊需求若业务必须允许某第三方类如com.example.SafeUtils可继承TemplateClassResolver实现白名单public class CustomClassResolver extends TemplateClassResolver { private static final SetString ALLOWED_CLASSES Set.of( java.lang.String, java.math.BigDecimal, com.example.SafeUtils ); Override public Class? resolve(String className) throws TemplateException { if (ALLOWED_CLASSES.contains(className)) { return super.resolve(className); } throw new TemplateException(Class not allowed: className, null); } } // 使用 cfg.setNewBuiltinClassResolver(new CustomClassResolver());注意自定义 resolver 必须严格校验className字符串禁止正则匹配如className.startsWith(java.)防止java.lang.Runtime绕过ALLOWED_CLASSES应硬编码不可从配置文件读取避免配置劫持。我坚持在每个新项目里把setNewBuiltinClassResolver(TemplateClassResolver.SAFEST_RESOLVER)作为Configuration初始化的第一行代码——它不像ObjectWrapper那样需要理解反射机制也不像TemplateLoader那样要处理路径逻辑就是一行确定性的安全声明。很多漏洞不是因为技术复杂而是因为开发者觉得“应该没问题”而忘了加这一行。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑