被派到现场做授权评估,目标是一个用Jinja2渲染页面内容的业务系统。模板内容可控,数据交互也很正常,标准的SSTI检测点。我先丢了一个最常见的探测串{{ 7*7 }},页面直接输出了49。再丢{{ ''.__class__.__mro__ }},拦截提示马上弹了出来。目标开了沙箱。
这个场景这两年越来越常见。很多开发团队已经知道不能直接拼接模板,SSTI漏洞的曝光度也上来了,于是大家开始用各种沙箱机制来"兜底"。但从我实际测过的项目来看,沙箱在多数场景下只能提高利用门槛,并不能从根本上拦住一个真正理解模板引擎底层机制的测试者。模板引擎本质上是一套与宿主语言共享对象模型的DSL,它的沙箱是策略层、配置层的限制,而不是语言级的隔离边界。
这篇文章我会把Jinja2、Twig、Freemarker这三个主流模板引擎的沙箱逃逸思路完整拆开讲,包括它们各自的沙箱实现方式、核心逃逸逻辑、版本差异,以及最后如何从攻击视角反推一套真正能落地的防御方案。内容适合已经接触过SSTI、想往深处走的安全测试者,也适合正在给模板渲染层做加固的开发者。
1. 沙箱存在的意义:模板引擎到底在防什么
1.1 先理清SSTI的本质
SSTI之所以危险,是因为模板并不是简单的"字符串拼接工具"。模板在渲染前会被编译成宿主语言代码(Python、PHP、Java),模板里的表达式、标签、过滤器最终都会进入执行流程。当用户输入能够影响模板的源码结构时,输入就成了代码的一部分。
关键在于理解模板引擎的分层模型。拿Jinja2举例,{{ user.name }}会被编译成对上下文对象user的属性访问,最终变成Python的getattr(user, 'name')。这意味着模板表达式天然具备对象操作能力。Jinja2在渲染时能访问的对象、内置过滤器、全局函数,都属于模板的"攻击面"。
沙箱瞄准的就是这个攻击面。沙箱的职责不是阻止所有代码执行,而是在"模板必须能访问对象属性和调用方法"和"不能访问os.system这类危险入口"之间画一条可配置的线。这条线靠什么实现?靠拦截器、白名单、名称过滤,而不是靠隔离进程或关闭解释器。这就是沙箱可以被穿透的根本原因。
1.2 三种引擎的沙箱定位差别
三种引擎对沙箱的定位很不一样,这也是很多人用起来感觉差异巨大的根源。
| 引擎 | 沙箱机制 | 默认安全级别 | 本质限制方式 |
|---|---|---|---|
| Jinja2 | SandboxedEnvironment | 较为严格 | 属性名过滤、is_safe_callable检查 |
| Twig | SandboxExtension + SecurityPolicy | 默认不启用,启用后看配置 | 白名单方法、过滤器、标签、函数 |
| Freemarker | NewObject策略 + TemplateClassResolver | 中低 | 限制new对象的类解析 |
Jinja2的沙箱偏向"黑名单+安全检查":默认阻止下划线开头的属性访问,维护一份"已知安全"的类型清单。Twig的沙箱偏向"白名单":只有显式允许的tags、filters、functions、methods才能用。Freemarker的沙箱则只盯着一个关键的入口——?new()能否实例化危险类,对其他漏洞点相对宽松。
这三套机制各自的薄弱环节完全不同。Jinja2的问题在于属性名过滤能不能覆盖所有等价访问;Twig的问题在于白名单配置的完整性和回调型过滤器的出口;Freemarker的问题在于Java反射链太丰富,类解析即使被限制,仍然存在其他拿到执行能力的路。接下来逐个拆。
2. Jinja2逃逸思路:从内省链到filter链
2.1 SandboxedEnvironment的检查逻辑
Jinja2的沙箱主体是sandbox.py里的SandboxedEnvironment。它重写了_getattr方法,逻辑并不复杂:如果属性名以_开头,直接拒绝;如果是_getattr内部信任的安全类型,如str、int、list这种基础设施类型,放行;其余情况交给is_safe_callable判断。
这个模型最典型的问题就是"属性访问的等价写法"。Jinja2模板语法里,user.name和user['name']都会走getattr,但沙箱只拦以_开头的属性名。在早期的沙箱实现里,attr过滤器并没有做同样的名称前缀检查,所以{{ ''.__class__ }}被拦,但{{ ''|attr("__class__") }}能直接打穿。
官方在后续版本里给attr补上了对__前缀的检查,我在本地验证环境中测试过,Jinja2 3.1.4之后|attr("__globals__")这类写法在沙箱下同样会触发拦截。早期版本里这个口子非常稳定,很多老系统的漏洞报告都出自这里。
2.2 内省链:拿到危险对象的几种路径
绕过沙箱拿到os模块或者__import__,核心依赖Python对象的内省机制。链路的起点一般是str子类实例,因为模板上下文里字符串最容易被我们控制。
{{ ''.__class__.__mro__[1].__subclasses__() }}这段如果在沙箱下直接用,会被属性名拦截直接掐死。但可以借助attr过滤器改写(注意版本差异,新版已修复):
{{ ''|attr('__class__')|attr('__mro__') }}拿到__subclasses__()后,老套路是逐个查找包含os模块引用的类。Python标准库中能间接拿到__import__的类至少有几十个,我常用的是warnings.catch_warnings下的_module.__builtins__['__import__']。这条路在非沙箱环境下很稳定,但在沙箱下还需要解决一个关键问题:过滤器的参数能不能携带函数名。
这就是filter链的用处。Jinja2的map、select、reject过滤器可以接收一个字符串参数来指定方法名,例如''|attr('__class__')|attr('__mro__')|map('__subclasses__')。字符串作为参数传给过滤器,绕开了沙箱对"属性访问"的检测面。在未完全修复的版本里,这种写法能把__subclasses__拉出来执行。
2.3 format与字符串格式化的另类属性访问
还有一条不被很多人注意的路径:字符串的format方法。Python的格式化字符串语法允许在花括号内访问对象属性:
{{ '{0.__class__}'.format('') }}格式化字符串内部的.不走getattr的沙箱检查路径,格式化语法自己的解析器会处理属性访问。这在旧版沙箱下能直接绕开属性名拦截。
Jinja2修复这个点的方式也很有意思——不是去改format,而是在沙箱里对格式化字符串本身做黑名单检查。我在测试中注意到,高版本对{0.__class__}这类字符串会直接抛SecurityError,但通过拼接等方式构造格式化串,在某些版本里仍然有操作空间。这说明黑名单方案天然存在覆盖不完整的顽疾。
2.4 版本差异与实测心得
| 版本区间 | 沙箱绕过难度 | 关键差异 |
|---|---|---|
| 2.x 及 3.0 早期 | 低 | attr、format 均可绕属性名过滤 |
| 3.1.x(补丁后) | 中 | attr/format 被修复,需找其他策略缺口 |
| 3.1.4+ | 中高 | 属性前缀检查覆盖更全,重点转向配置错误 |
实测下来,沙箱真正能挡住大部分攻击者的场景,一是版本足够新,二是开发者没有在上下文里暴露额外对象。很多人配置沙箱时忽略了一个问题:SandboxedEnvironment并不能阻止模板访问上下文中已有的对象及其方法。如果业务代码把数据库连接、Redis客户端、甚至某个工具类塞进了模板上下文,那沙箱形同虚设。这类"沙箱没被打穿但上下文泄露直接GG"的案例,我在实际评估里遇到的比纯逃逸链还多。
3. Twig逃逸思路:PHP对象模型下的另类路径
3.1 SecurityPolicy的白名单机制
Twig默认并不启用沙箱,启用后主要通过SandboxExtension配合SecurityPolicy工作。SecurityPolicy管理五个维度的白名单:tags、filters、functions、methods、properties。看起来比Jinja2的黑名单思路严谨,但白名单机制的弱点在于:配置者的疏漏会直接变成漏洞面。
最经典的配置问题是methods白名单。业务需要调用某个对象的getStatus、format这类方法,于是把方法名加进白名单。但PHP的方法解析不区分大小写,而且Twig的方法调用走__call魔术方法时,SecurityPolicy的方法名检查有时会被跳过。某些版本中,白名单配了format,攻击者在模板里调用format的变体写法(如大小写混合)就能绕过。
3.2 回调型过滤器:sort/filter/map的入口
Twig逃逸链里目前最活跃的一支是回调型过滤器。sort、filter、map这几个过滤器支持传入箭头函数作为回调。箭头函数本身是Twig模板语法,但回调参数实际上是PHP闭包,闭包体内可以调用任意"可用"的方法或函数。
思路是这样的:先通过箭头函数拿到一个可执行回调的入口,再在闭包参数上做文章。举个例子,sort过滤器在比较两个对象时,箭头函数被调用,此时如果能触发对象的某个方法,且该方法恰好是__construct这类PHP魔术方法,就有机会把参数传递到底层危险方法。
{{ ['a','b']|sort((a, b) => call(user_func等)) }}这类写法受限于沙箱的函数白名单,所以在严格配置下直接调用call_user_func是会被拦的。但方法的白名单如果包含了__construct,攻击者可以构造一个具有危险构造参数的对象引用,利用排序回调把可控数据注入构造参数。这类路径对PHP版本和应用层的依赖较强,没有绝对通用的payload,但思路是统一的:找一个沙箱允许执行的用户代码入口,把参数精准导向危险函数。
我自己在实际测试中更喜欢先探测沙箱的过滤器白名单,做法是构造{{ 'id'|filter('system') }}这类payload看报错信息。如果报错提示filter不允许,就换map、reduce、sort逐个试。关键是找一个能接收用户函数、且在沙箱白名单内没有覆盖的过滤器。
3.3 方法白名单配置不当时的路子
Twiig沙箱的SecurityPolicy能控制"模板中允许调用的方法",但这个控制粒度很粗。allowed_methods的键值对应的是类名,Twig在运行时判断当前对象是否属于某个允许的类,然后放行该类的所有方法调用。这就带来一个配置悖论:如果业务把某个内部工具类加进白名单,攻击者只要能让模板上下文里出现该工具类的实例,就能调用它的一切公有方法。
我见过一个案例:业务为了在模板中展示日期,把某个日期时间工具类加进了allowed_methods。这个工具类有个setFromArray方法能修改对象属性,顺着这个入口一路调用,最终通过已存在上下文中的__toString、__call走到了可利用的代码面。这类利用依赖具体业务,没法给通用payload,但排查思路很清晰:拿到方法白名单列表,逐个看这些方法的作用,再想如何串联。
3.4 PHP对象的敏感方法面
和Jinja2的环境不同,PHP生态的方法面更宽。call_user_func、system、exec、passthru这类函数在模板侧能不能直接调用,取决于functions白名单;但对象方法不受函数白名单约束,只受methods白名单约束。
如果methods白名单为空或者配置了*(允许所有方法),那模板任意对象的方法调用都放行。这种情况下就算不能直接调用系统函数,只要能拿到一个内部类实例(比如日志类、缓存类、文件处理类),其方法内部通常埋着文件读写或者命令执行的能力。所以Twig沙箱的逃逸,很多时候不是沙箱机制本身有洞,而是白名单配置的覆盖面和它背后允许的对象方法面根本不匹配。
4. Freemarker逃逸思路:Java反射链与内建函数的边界
4.1 模板内建函数与对象模型
Freemarker的模板表达式同样可以访问模型对象的属性和方法,它的内建函数(built-in)体系非常庞大,这是它SSTI攻击面广阔的重要原因。?new、?eval、?string、?js_string这些内建函数各有各的问题面。内建函数在Freemarker中的定位是模板语法的组成部分,沙箱通常不会去禁用它们——因为禁用内建函数等于把模板引擎阉割了。
Freemarker的沙箱逻辑集中在 "到底允许模板创建哪些Java类" 这一点上,其中?new是核心入口。模板里可以写:
<#assign x = "freemarker.template.utility.Execute"?new()>一旦成功实例化Execute,就能直接执行系统命令。Freemarker历史上最经典的三个可执行工具类:
| 类名 | 作用 |
|---|---|
| freemarker.template.utility.Execute | 执行系统命令 |
| freemarker.template.utility.ObjectConstructor | 实例化任意Java类 |
| freemarker.template.utility.JythonRuntime | 执行Jython脚本 |
ObjectConstructor最危险的地方在于能实例化任意Java类,配合Execute或者ScriptEngineManager这类类,命令执行和脚本执行都能覆盖。这些工具类在新版本中被默认限制,但这个默认限制依赖于配置的TemplateClassResolver。
4.2 ?new与TemplateClassResolver的绕过面
Freemarker从某一版本开始引入TemplateClassResolver来校验?new的类名。默认的TemplateClassResolver会拒绝freemarker.template.utility.*包下的类实例化。看起来安全了,但Java反射链的攻击面还是足够深。
常见的思路是绕开freemarker.template.utility这个包名限制,寻找其他能够间接执行代码的类。例如javax.script.ScriptEngineManager,它本身不是freemarker.template.utility包的,通过ObjectConstructor实例化它之后可以加载JavaScript引擎并执行脚本。某些Java版本默认自带Nashorn引擎,这就是一条完整的执行链。
另一个经典绕法是利用freemarker.ext.beans.BeansWrapper及其子类。模板在解析对象属性时实际上依靠BeansWrapper来做JavaBean反射,它允许模板调用Java对象的公有方法。只要能拿到一个叫Class的对象引用(比如某个对象的class属性),理论上可以通过反射拿getRuntime、exec,最终走向命令执行。Freemarker对class属性访问的处理相比Jinja2宽松得多,这是两个引擎对"Java反射"友好度的差异。
4.3 除了?new以外,还有哪些入口
?new被堵死不代表结束。Freemarker的<#assign>配合?eval可以做二次解析;?eval本身不执行Java代码,但它可以递归调用模板表达式,在某些配置下能绕过类解析器的检测面。
还有一条容易被忽略的路:数据模型的入口。Freemarker模板能访问的数据模型(DataModel)中包含的对象,是TemplateHashModel、TemplateMethodModel等Freemarker内置接口的实现。如果模型中有自定义的TemplateMethodModel,那么它的exec方法就是模板侧的代码执行入口。exec方法名在Freemarker内部是约定俗成的接口方法,不会被沙箱拦截,因为沙箱根本不拦接口方法——它只拦类名解析。这个出口在真实业务系统里经常出现:开发者为了方便模板调用,会把一些通用的"工具方法"封装成TemplateMethodModel放进模型,却忽略了这些方法的参数可能来自用户输入。
4.4 Freemarker版本差异的实测感受
| 版本 | 默认状态 | 逃逸难度 |
|---|---|---|
| 2.3.22及更早 | ?new无限制 | 低 |
| 2.3.23起 | 引入TemplateClassResolver | 中 |
| 2.3.30+ | 默认阻止utility包 | 中高 |
Freemarker还有一个其他引擎没有的特性:它对模板中直接出现的静态类名引用(如freemarker.template.utility.Execute?new())限制较严,但对通过变量间接持有的类对象引用限制较松。Java的class对象是运行时第一公民,一旦某个对象的class属性被模板拿到,后面的展开面就大了。所以Freemarker逃逸的实战思路我一直强调:不要只盯着?new字符串,要想办法在模板上下文里找到已经存在的类对象引用。
5. 三引擎对照:逃逸路径共性分析与防御落地
5.1 一张表看清三引擎的沙箱思路
把三个引擎放在一起对比,能清楚看到它们的设计哲学差异,也能看到共性。
| 维度 | Jinja2 | Twig | Freemarker |
|---|---|---|---|
| 沙箱默认开启 | 需显式使用SandboxedEnvironment | 否,需手动启用 | 部分策略默认 |
| 限制方式 | 属性名黑名单+安全检查 | 全维度白名单 | 类解析白名单 |
| 主要逃逸面 | filter参数化携带方法名 | 回调过滤器+方法白名单覆盖不全 | 类对象引用+反射 |
| 经典入口 | __class__内省链 | sort/filter箭头函数 | ?new + TemplateMethodModel |
| 版本落差 | 补丁后难度明显上升 | 依赖配置粒度 | 依赖类解析策略 |
共性其实很突出:三个引擎的沙箱都试图在"模板语言能力"和"宿主语言对象面"之间划界,但沙箱的判定依据全是名称、签名、白名单这些"语法级"的特征,而不是真正的运行时隔离。只要模板能拿到对象的引用,对象的方法真正能不能被调用,最终还是由宿主语言的动态性决定——Python的__getattr__、PHP的__call、Java的反射,都在绕过沙箱判定后提供了第二层通道。
5.2 从逃逸链反推的共性防御原则
看过这么多逃逸链之后,我得出的防御结论和网上常见的"配置好沙箱就安全"很不一样。沙箱只是最后一层兜底,真正有效的防御应该前移。
第一原则:不可信输入永远不要进入模板结构本身。模板注入的前提是输入能影响模板源码。把用户输入绑定为模板上下文中的一个变量值,即使表达式注入失败,攻击者也拿不到模板语法执行权。这是最根本的解药,沙箱只是缓解。
第二原则:最小化暴露。模板上下文只放渲染所需的最小对象集。能用普通类型(字符串、数字、列表、字典)解决的,绝不塞业务对象。很多系统把Service层、DAO层直接塞进模板上下文,等于把整个业务后门都送给了模板表达式。我在评估中遇到的多数高影响SSTI,靠的其实不是沙箱逃逸,而是上下文里那个可以直接用的工具类。
第三原则:白名单宁可过度收紧也不要先放宽。Twig的SecurityPolicy、Freemarker的类解析器、Jinja2的is_safe_callable,都需要按最小必需原则配置。特别是Twig,默认不启用沙箱的项目就别提防御了;启用后全维度白名单要逐个过一遍,别想当然认为"能跑就行"。
5.3 防御落地清单
结合三种引擎的特点,我给出一份可以直接拿去对照落地的检查清单:
- 确认模板引擎版本已升级到最新,三个引擎都在持续修补沙箱绕过,版本落后就没有讨论防御的意义。
- 不可信输入一律作为上下文值传入,禁止拼接模板源码。可以在代码评审、CI脚本里加正则扫描,检索
Template、render附近的字符串拼接。 - Jinja2项目检查是否启用
SandboxedEnvironment,并自定义is_safe_callable收紧允许调用的对象类别。 - Twig项目检查
SandboxExtension是否启用,SecurityPolicy五个维度的白名单逐项核实,禁止*通配。 - Freemarker项目检查
TemplateClassResolver配置,拒绝freemarker.template.utility.*,同时排查模型中自定义的TemplateMethodModel是否暴露风险方法。 - 模板上下文对象清单要纳入资产管理和代码评审,不用的对象及时移除。
- 加一层针对SSTI特征的WAF规则:类名探测特征(
__class__、__mro__、__subclasses__)、?new、Execute、ObjectConstructor等关键字。WAF不是防御的核心,但能在漏洞被利用时争取响应时间。
5.4 检测侧的思路补充
防御方还需要一个持续检测SSTI的机制。审计模板渲染点的时候,我习惯构造三类探针:数学运算探针(7*7)、属性访问探针(''.__class__、'a'|attr('__class__')、?new)、过滤器/方法面探针('id'|filter('system')、<#assign x='freemarker.template.utility.Execute'?new())。这三类覆盖了模板引擎最吃的语法入口,只要目标返回了运算结果或者对象信息,就说明表达式被解析执行了。
真正的检测难点在闭源系统或者模板内容来自数据库的场景,没有源码审计线索就只能靠黑盒探针。这种情况下我会多试几个编码层级的变形(URL编码、Unicode转义、HTML实体),因为很多系统在存储模板前会做一次解码,底层引擎拿到的是解码后的源。
写在最后的个人体会
沙箱逃逸这件事,我从测试者角度和开发者角度都蹲过。一个很深的感受是:模板沙箱给开发者造成的"安全错觉"比漏洞本身更危险。很多人配好沙箱就松了弦,觉得"反正底层有防护",结果在上下文里塞了一堆业务对象,或者把模板拼接口子留在了某个管理后台。等到出问题,沙箱连拦都来不及拦。
另一个体会是版本管理的重要性。Jinja2的attr绕过、Freemarker的ObjectConstructor链,在不同版本下的可用性差异极大。评估过程中我先看的永远是版本号和配置项,这比背payload有用得多。安全对抗就是这样,不是谁记住的exp多谁就强,而是谁更理解一套机制背后的设计取舍,谁就能在补丁出现时第一时间找到下一条路。
最后分享一个小技巧:在自建验证环境里把三个引擎的沙箱开关都打开,用同一套探针脚本分别打,观察它们的报错信息风格。报错差异就是这个引擎心智能模型的直接体现,看多了之后,黑盒测SSTI时你基本能从报错推断出后端是Python还是PHP还是Java,连指纹识别都省了。这套手感,值得每一个做Web安全的人花一个下午去练。