回到了 Agent Finding 的一天,今天要講的是 Anthropic Mythos 發現的
twigphp/twig - code injection [1]
這邊先簡單說明一個 Template 具體被 Compile 成我們看到的前端畫面到底經過了哪些流程:
網路上找不到好的圖所以我把文字丟給 Gemini 生了這張東西

簡單來說,每一個模板段落 {%...%} 會被拆成一個個 Node 再塞進 AST (語法樹)解析,分段進去獨立的 PHP Code Generation。
接著產生這份 PHP Code 後,才會再回去原本的 Context 做其餘執行/Render 等動作,而直到這邊才加入了我們的 Sandbox 保護。
如果 Pre Compile 的過程中就有漏洞了呢...?
這就是我們的 CVE-2026-46633 Sandbox Bypass
以有漏洞的 v3.25.0 為例
https://github.com/twigphp/Twig/blob/v3.25.0/src/Node/ModuleNode.php#L245-L274 [2]
這段是在使用者嘗試引入的模板不在當前 block 內時提出報錯的程式碼
foreach ($trait->getNode('targets') as $key => $value) {
$compiler
->write(\sprintf('if (!isset($_trait_%s_blocks[', $i))
->string($key)
->raw("])) {\n")
->indent()
->write("throw new RuntimeError('Block ")
->string($key)
->raw(' is not defined in trait ')
->subcompile($trait->getNode('template'))
->raw(".', ")
->repr($node->getTemplateLine())
->raw(", \$this->source);\n")
->outdent()
->write("}\n\n")
...<SNIP>...
}
}
理論上名稱經過 subcompile 後,應該產生的結果是:
throw new RuntimeError('Block absent is not defined in trait "traits.html".');
可是在經過 Compiler::string() 時, Compiler 忘記去過濾單引號(這邊有記得過濾別的唯獨沒有單引號 XD):
https://github.com/twigphp/Twig/blob/v3.25.0/src/Compiler.php#L144-L149 [3]
public function string(string $value)
{
$this->source .= \sprintf('"%s"', addcslashes($value, "\0\t\"\$\\"));
return $this;
}
就這樣導致在 throw error 時可以達成 code injection。
e.g. 引入一個 trait 是
'.system('whoami').'.html
就直接 RCE 了,因為生成出來的內容是:
throw new RuntimeError('Block absent is not defined in trait "'.system('whoami').'.html".');
單引號閉合!
整體而言攻擊流程就是:不合法的 trait name 引入 => 走到報錯分支產生 Code Injection 程式碼
2023 年就有來自頂會 USENIX 的 Paper 在討論這件事情了:
Remote Code Execution from SSTI in the Sandbox: Automatically Detecting and Exploiting Template Escape Bugs [4]
甚至當時還未成功 FUZZ 出任何 Twig Sandbox Escape 呢 xDDD
其實可以想見過去有很多各種(尤其針對邏輯洞的) Fuzzer 會因為走不到一些神奇的 Exception/走到 Exception 就直接判斷不合法退出 等等原因導致許多這樣的分支被漏看,但對於 Agent 不論是直接追完整份 code;抑或是 source <-> sink 這樣下去想辦法串起來,我相信在輔佐人類過去的 Fuzzing 科技上都會很有幫助!