沒錯,有許多 Template Engine 其實都有內建的沙盒保護。不同於大家最熟知的 Flask/Jinja2,今天就挑 PHP Twig 來談談他們過去由人類發展出來的各種技術堆疊。
你可能沒聽過 Twig,但做後端/PHP 開發的應該多少都有接觸過 Symfony 這套框架,而 Twig 就是 Symfony 原生的模板引擎。
Twig 一般的 Environment 同樣把 Template Source 當作受信任程式碼;Twig 官方現在的 Security Policy 也直接寫明,只有 Sandbox 才是執行 Untrusted Template 的安全邊界 [1]。在 2015 年的 Twig 1.x 中,啟用方式大致長這樣:
$policy = new Twig_Sandbox_SecurityPolicy(
array('if'), // allowedTags
array('escape'), // allowedFilters
array('User' => array('getName')), // allowedMethods
array('User' => array('name')), // allowedProperties
array() // allowedFunctions
);
$sandbox = new Twig_Extension_Sandbox($policy, true);
$twig->addExtension($sandbox);
Twig_Sandbox_SecurityPolicy 的五個參數依序就是允許使用的 Tag、Filter、Object Method、Object Property 與 Function。Method/Property 會再依 Class 分組,所以這份 Policy 的意思是:Template 可以呼叫 User::getName() 與讀取 User::$name,但不能因為拿到了 User Object,就順手呼叫上面的其他 Public Method。
Twig_Extension_Sandbox($policy, true) 的第二個參數代表全域啟用 Sandbox。它大致分成兩層檢查:
Template Compile
-> NodeVisitor 收集 Tag/Filter/Function
-> 在編譯結果的進入點插入 checkSecurity()
Template Render Start
-> checkSecurity() 對照 Allow List
Template Runtime
-> Object Method/Property Access
-> checkMethodAllowed()/checkPropertyAllowed()
-> 再依 Object Class 對照 Allow List
這個設計並沒有什麼問題:在 Compile 階段確定的語法就先檢查,必須等到 Runtime 才知道實際 Class 的 Object Access 則晚一點再檢查。理論上,只要所有存取都會經過這兩層 Policy,就算 Template 是攻擊者寫的,也只能使用開發者親自允許的能力。
Sandbox Bypass 出現在同一篇研究的下一段。照理說,如果開發者只允許 userObject.safeMethod(),那 Template 就不該呼叫 userObject.vulnerableMethod();但當時 checkMethodAllowed() 中存在下面這條特例:
public function checkMethodAllowed($obj, $method)
{
if ($obj instanceof Twig_TemplateInterface || $obj instanceof Twig_Markup) {
return true;
}
// 其他 Object 才會繼續查詢 allowedMethods
// ...
}
只要 Object 實作 Twig_TemplateInterface 或屬於 Twig_Markup,任何 Method 都會直接視為允許。這條捷徑原本是為了讓 Twig 自己的 Template/Markup 物件能正常運作;偏偏 Sandbox 中一定存在的 _self,剛好就是目前的 Twig_Template,也實作了 Twig_TemplateInterface。
也就是說,攻擊者不用在 allowedMethods 裡找到任何東西,呼叫 _self 的 Method 就會直接通過。而 _self 上剛好有一個給 Twig 內部使用的 displayBlock():
public function displayBlock($name, array $context, array $blocks = array(), $useBlocks = true)
{
if ($useBlocks && isset($blocks[$name])) {
$template = $blocks[$name][0];
$block = $blocks[$name][1];
}
if (null !== $template) {
$template->$block($context, $blocks);
}
}
$blocks 原本應該是 Twig 內部維護的 Block Table,每一筆內容是 [Template Object, Block Method Name]。但這個 Method 又是 Public,而且沒有驗證 Table 裡的第一個元素真的是 Template,因此 James Kettle 構造出下面這段 Payload:
{{_self.displayBlock("id",[],{"id":[userObject,"vulnerableMethod"]})}}
每個參數拆開來看就很清楚:
"id" 是 $name,也就是準備顯示的 Block 名稱。[] 是 $context,這次呼叫要傳給 Block 的變數環境。{"id":[userObject,"vulnerableMethod"]} 是攻擊者自製的 $blocks;其中 $blocks["id"][0] 會變成 $template = userObject,$blocks["id"][1] 則會變成 $block = "vulnerableMethod"。$useBlocks 沒有明寫,所以使用預設值 true,程式便會優先採用攻擊者送進來的 $blocks。最後的 Dynamic Dispatch 就不再是呼叫真正的 Twig Block,而是:
$template->$block($context, $blocks);
// 等同 userObject->vulnerableMethod($context, $blocks)
整條 Bypass Chain 也就變成:
_self 實作 Twig_TemplateInterface
-> checkMethodAllowed(_self, "displayBlock") 無條件放行
-> displayBlock() 信任攻擊者提供的 Block Table
-> 動態呼叫 userObject->vulnerableMethod()
-> 目標 Method 沒有再經過 SecurityPolicy
這裡最關鍵的地方是:Policy 確實有檢查 Method,只是它檢查的是 _self.displayBlock();真正危險的 userObject->vulnerableMethod() 發生在 displayBlock() 內部,Twig 沒有再做第二次檢查。
能不能直接 RCE,仍然取決於開發者把哪些 Object 放進 Template Context;但只要其中有能寫檔、執行程式,或能繼續取得其他危險 Object 的 Public Method,Sandbox 的 Method Allow List 就形同虛設。
Twig 1.20.0 的 Patch [2] 非常精準:既然 Block Table 的 Target 理論上只能是編譯後的 Twig Template,那就在 Dynamic Dispatch 前把這個 Invariant 寫成檢查 [3]:
if (null !== $template) {
+ // avoid RCEs when sandbox is enabled
+ if (!$template instanceof Twig_Template) {
+ throw new \LogicException('A block must be a method on a Twig_Template instance.');
+ }
+
try {
$template->$block($context, $blocks);
修補後,$template 如果是 userObject 就會在 Dynamic Dispatch 前直接丟出 Exception;只有真正的 Twig_Template 才能成為 Block Target。如此一來,$block 即使仍然是字串,也只能指向 Twig 編譯出的 Template Method,不能再把任意 Application Object 當作跳板。
1.20.0 同時禁止 Template 透過 env、environment 或 getEnvironment() 取得 Twig Environment,並棄用 _self 在 from/import 以外的用途。前面那條未開 Sandbox 時的 registerUndefinedFilterCallback("exec") Chain,也因此一起失去入口。