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

三大模板引擎SSTI沙箱逃逸深度解析与防御实践

三大模板引擎SSTI沙箱逃逸深度解析与防御实践 ★ FEATURED ARTICLE
被派到现场做授权评估目标是一个用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 三种引擎的沙箱定位差别三种引擎对沙箱的定位很不一样这也是很多人用起来感觉差异巨大的根源。引擎沙箱机制默认安全级别本质限制方式Jinja2SandboxedEnvironment较为严格属性名过滤、is_safe_callable检查TwigSandboxExtension SecurityPolicy默认不启用启用后看配置白名单方法、过滤器、标签、函数FreemarkerNewObject策略 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 一张表看清三引擎的沙箱思路把三个引擎放在一起对比能清楚看到它们的设计哲学差异也能看到共性。维度Jinja2TwigFreemarker沙箱默认开启需显式使用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 xfreemarker.template.utility.Execute?new()。这三类覆盖了模板引擎最吃的语法入口只要目标返回了运算结果或者对象信息就说明表达式被解析执行了。真正的检测难点在闭源系统或者模板内容来自数据库的场景没有源码审计线索就只能靠黑盒探针。这种情况下我会多试几个编码层级的变形URL编码、Unicode转义、HTML实体因为很多系统在存储模板前会做一次解码底层引擎拿到的是解码后的源。写在最后的个人体会沙箱逃逸这件事我从测试者角度和开发者角度都蹲过。一个很深的感受是模板沙箱给开发者造成的安全错觉比漏洞本身更危险。很多人配好沙箱就松了弦觉得反正底层有防护结果在上下文里塞了一堆业务对象或者把模板拼接口子留在了某个管理后台。等到出问题沙箱连拦都来不及拦。另一个体会是版本管理的重要性。Jinja2的attr绕过、Freemarker的ObjectConstructor链在不同版本下的可用性差异极大。评估过程中我先看的永远是版本号和配置项这比背payload有用得多。安全对抗就是这样不是谁记住的exp多谁就强而是谁更理解一套机制背后的设计取舍谁就能在补丁出现时第一时间找到下一条路。最后分享一个小技巧在自建验证环境里把三个引擎的沙箱开关都打开用同一套探针脚本分别打观察它们的报错信息风格。报错差异就是这个引擎心智能模型的直接体现看多了之后黑盒测SSTI时你基本能从报错推断出后端是Python还是PHP还是Java连指纹识别都省了。这套手感值得每一个做Web安全的人花一个下午去练。
阅读完成 · 觉得有帮助?
咨询建站