端模板注入:從探測到利用鏈構(gòu)造與防御繞過)
1. 從一次模板渲染異常說起SSTI到底在利用什么很多人第一次接觸模板注入這個概念是在做代碼審計或者CTF題目的時候。輸入框里隨手敲一個{{7*7}}頁面回顯49那一瞬間既興奮又困惑——興奮的是找到了一個疑似注入點(diǎn)困惑的是這玩意兒到底能走多遠(yuǎn)、能干什么、為什么一個模板引擎會把用戶輸入當(dāng)代碼執(zhí)行。我當(dāng)初也是這樣盯著一個回顯的49愣了半天后來才慢慢把整條利用鏈路摸清楚。先把概念說清楚。SSTI全稱 Server-Side Template Injection服務(wù)端模板注入。它的本質(zhì)是用戶可控的輸入被拼接進(jìn)了模板內(nèi)容里而模板引擎在渲染時把這段輸入當(dāng)成了模板語法的一部分去解析執(zhí)行。注意這里的關(guān)鍵詞是拼接進(jìn)模板內(nèi)容而不是作為變量值傳入模板。這兩者的區(qū)別恰恰是正常使用和漏洞之間的分水嶺。拿 Jinja2 舉例。正常的寫法是render_template_string(Hello {{ name }}, nameuser_input)用戶輸入是作為變量name的值傳進(jìn)去的模板引擎只會把它當(dāng)字符串渲染哪怕你輸入{{7*7}}頁面上顯示的也是原樣的{{7*7}}。而有漏洞的寫法是render_template_string(Hello user_input)用戶輸入直接成了模板源碼的一部分這時候{{7*7}}就會被當(dāng)成表達(dá)式求值回顯49。這個區(qū)別聽起來簡單但實(shí)際審計中非常容易看走眼。因為很多框架封裝得很深你看到的是一個render調(diào)用但參數(shù)到底怎么傳的、有沒有經(jīng)過字符串拼接得順著調(diào)用鏈一層層看。我見過不少項目表面上用了模板引擎實(shí)際上在某個角落把用戶輸入拼進(jìn)了模板字符串這種地方就是雷區(qū)。這篇內(nèi)容適合誰看如果你是在做 Web 安全學(xué)習(xí)、代碼審計、滲透測試入門或者單純想搞明白模板注入這四個字背后到底發(fā)生了什么那接下來的內(nèi)容應(yīng)該能幫到你。我會圍繞 Jinja2 這個具體引擎把探測、利用鏈構(gòu)造、常見繞過、以及防御視角的排查思路都過一遍。需要提前說明的是所有內(nèi)容僅用于授權(quán)環(huán)境下的安全研究與防御加固請勿用于任何未授權(quán)的目標(biāo)。另外要強(qiáng)調(diào)一點(diǎn)SSTI 不是 Jinja2 獨(dú)有的Twig、Freemarker、Velocity、Smarty 等引擎都有類似問題但每個引擎的語法、可利用對象、利用鏈差異很大。本文聚焦 Jinja2是因為它在 Python 生態(tài)里出現(xiàn)頻率極高而且利用鏈相對成熟、資料相對豐富適合作為理解 SSTI 的切入點(diǎn)。把 Jinja2 這套邏輯吃透之后再看其他引擎思路是相通的只是 payload 要換。2. 探測階段怎么確認(rèn)一個點(diǎn)是不是真的能注入2.1 先分清回顯了和執(zhí)行了探測 SSTI 最容易犯的錯誤是把輸入被原樣顯示當(dāng)成注入成功。比如你輸入{{7*7}}頁面返回{{7*7}}這不叫注入這叫正常轉(zhuǎn)義輸出。真正的注入是返回49。還有一種情況是輸入{{7*7}}返回空或者報錯這可能是語法被解析了但求值失敗也可能是被過濾了需要進(jìn)一步判斷。我習(xí)慣用一組數(shù)學(xué)表達(dá)式 字符串表達(dá)式的組合來交叉驗證而不是只測一個{{7*7}}。原因很簡單有些環(huán)境會對數(shù)字做特殊處理或者某些 WAF 規(guī)則專門盯著7*7這種經(jīng)典 payload。用多個表達(dá)式交叉驗證能降低誤判概率。常用的探測 payload 組合如下探測目的Payload預(yù)期回顯說明基礎(chǔ)表達(dá)式求值{{7*7}}49最經(jīng)典的入門探測字符串拼接{{ab}}ab驗證字符串運(yùn)算能力乘法與字符串混合{{7*7}}7777777Jinja2 中字符串乘法Twig 結(jié)果不同可用于區(qū)分引擎變量訪問{{config}}配置對象內(nèi)容驗證是否能訪問內(nèi)置對象過濾器調(diào)用{{abcupper}}ABC這里重點(diǎn)說一下{{7*7}}這個 payload。在 Jinja2 里7*7的結(jié)果是字符串7重復(fù) 7 次即7777777而在 Twig 里同樣的表達(dá)式結(jié)果可能是49取決于版本和類型轉(zhuǎn)換。這個差異在指紋識別階段非常有用能幫你快速判斷后端到底是哪個引擎。類似的還有{{abc|upper}}這種過濾器調(diào)用不同引擎的過濾器名稱和可用性不一樣多測幾個就能縮小范圍。2.2 上下文決定 payload 的寫法探測的時候不能脫離上下文。同一個注入點(diǎn)出現(xiàn)在 HTML 正文里、出現(xiàn)在 HTML 屬性里、出現(xiàn)在 JavaScript 代碼塊里、出現(xiàn)在 CSS 里payload 的構(gòu)造方式完全不同。舉個最直觀的例子如果注入點(diǎn)在script標(biāo)簽內(nèi)部你直接寫{{7*7}}可能被 JavaScript 語法干擾需要先閉合或者用注釋包裹如果注入點(diǎn)在 HTML 屬性值里可能要考慮引號閉合問題。我踩過的一個坑是某個注入點(diǎn)在input value用戶輸入這種屬性位置我直接塞{{7*7}}頁面返回的確實(shí)是49但整個 input 標(biāo)簽結(jié)構(gòu)被破壞了導(dǎo)致后續(xù)利用鏈根本沒法正?;仫@。后來改成{{7*7}}前面加個空格、后面用//之類的注釋處理才讓結(jié)構(gòu)恢復(fù)正常。這個細(xì)節(jié)說明探測階段就要開始觀察注入點(diǎn)周圍的語法環(huán)境而不是只盯著回顯值。提示探測時建議同時記錄三樣?xùn)|西——原始輸入、頁面回顯、以及回顯位置在 HTML 源碼中的上下文。這三樣信息在后續(xù)構(gòu)造利用鏈時都會用到。2.3 被過濾時的降級探測思路現(xiàn)實(shí)環(huán)境里{{和}}被過濾是家常便飯。這時候不要急著放棄先判斷過濾的粒度。是過濾了雙大括號還是過濾了花括號還是過濾了關(guān)鍵字不同的過濾強(qiáng)度對應(yīng)不同的繞過思路。如果{{被過濾可以試試 Jinja2 的另一種語法{% ... %}語句塊。雖然語句塊主要用于控制結(jié)構(gòu)但某些場景下也能觸發(fā)求值。另外Jinja2 支持{{的變體寫法比如用{% print(7*7) %}這種形式取決于版本和配置。如果花括號整體被過濾那基本可以判斷這個點(diǎn)走不通 SSTI得換思路。如果只是關(guān)鍵字被過濾比如config、self、__class__這些被拉黑那就進(jìn)入利用鏈構(gòu)造階段的繞過話題了這個后面單獨(dú)講。探測階段的核心目標(biāo)只有一個確認(rèn)這個點(diǎn)是否真的把輸入當(dāng)模板解析了。確認(rèn)了這一點(diǎn)后面的利用才有意義。3. 利用鏈構(gòu)造從{{7*7}}到命令執(zhí)行要跨過幾道坎3.1 為什么不能直接執(zhí)行系統(tǒng)命令很多人探測到{{7*7}}回顯49之后第一反應(yīng)是能不能直接{{system(whoami)}}。答案是不能。Jinja2 的模板沙箱如果啟用了或者默認(rèn)的全局命名空間里根本沒有system、exec、os這些名字。模板能訪問的只有渲染時傳入的上下文變量以及 Jinja2 內(nèi)置的一些全局對象和過濾器。所以利用鏈的本質(zhì)是從模板能訪問到的對象出發(fā)通過屬性訪問、方法調(diào)用、繼承關(guān)系一步步爬到能執(zhí)行命令或者讀寫文件的對象上。這個過程在 Python 里之所以可行是因為 Python 的對象模型非常開放——幾乎任何對象都能通過__class__、__bases__、__subclasses__、__globals__這些魔術(shù)屬性爬到其他類和方法上。理解這一點(diǎn)很關(guān)鍵。SSTI 利用鏈不是某個固定的 payload而是一套對象漫游的方法論。你掌握了這套方法論即使目標(biāo)環(huán)境過濾了某些關(guān)鍵字也能換一條路爬過去。3.2 經(jīng)典利用鏈的逐步拆解最經(jīng)典的 Jinja2 利用鏈起點(diǎn)通常是空字符串、字符串字面量或者某個內(nèi)置對象。以空字符串為例逐步拆解第一步.__class__拿到字符串類class str。這一步的意義是從任意一個實(shí)例出發(fā)拿到它的類。第二步.__class__.__mro__拿到方法解析順序元組里面包含str、object等類。__mro__是 Python 新式類的屬性返回一個元組按順序列出繼承鏈上的所有類。對于字符串來說通常是(class str, class object)。第三步.__class__.__mro__[1]拿到object類。所有 Python 類最終都繼承自object所以這一步是爬到根。第四步.__class__.__mro__[1].__subclasses__()拿到object的所有直接子類列表。這個列表非常長里面包含了當(dāng)前 Python 進(jìn)程里加載過的各種類。這一步是整個利用鏈的核心——因為列表里往往能找到subprocess.Popen、os._wrap_close、warnings.catch_warnings等有用的類。第五步從子類列表里定位到目標(biāo)類比如subprocess.Popen然后通過__init__.__globals__拿到該類的全局命名空間里面通常有os、sys等模塊的引用。第六步通過全局命名空間里的os模塊調(diào)用popen或者system執(zhí)行命令。把上面六步串起來一個典型的 payload 長這樣{{.__class__.__mro__[1].__subclasses__()[索引].__init__.__globals__[os].popen(id).read()}}這里的索引需要根據(jù)目標(biāo)環(huán)境實(shí)際返回的子類列表來確定因為不同 Python 版本、不同加載模塊下subprocess.Popen在列表里的位置不一樣。這也是為什么很多教程里的 payload 直接抄過來跑不通——索引對不上。3.3 定位子類索引的實(shí)操方法怎么確定索引最直接的辦法是把__subclasses__()的結(jié)果整個打印出來然后人工找。但結(jié)果往往很長幾百個類肉眼找很費(fèi)勁。我一般用兩種方式第一種用過濾器或者字符串方法做篩選。比如{{.__class__.__mro__[1].__subclasses__()|string|truncate(500)}}先看個大概或者用select過濾器配合equalto做精確匹配。不過 Jinja2 的過濾器能力有限復(fù)雜篩選不太好寫。第二種本地復(fù)現(xiàn)。在自己的環(huán)境里跑一個相同 Python 版本的腳本把object.__subclasses__()打印出來找到目標(biāo)類的索引再拿到目標(biāo)環(huán)境去試。這個方法的前提是能大致判斷目標(biāo)環(huán)境的 Python 版本。判斷版本的方法很多比如通過報錯信息、通過某些類的存在與否、通過{{.__class__.__mro__}}的輸出格式等。這里有個經(jīng)驗subprocess.Popen不一定總在列表里因為如果目標(biāo)進(jìn)程沒有 import 過 subprocess這個類就不會被加載。這時候可以退而求其次找os._wrap_close通常由 os 模塊加載時產(chǎn)生、warnings.catch_warnings標(biāo)準(zhǔn)庫常用等類它們的__init__.__globals__里往往也能摸到os或者sys。3.4 不依賴子類索引的替代路徑子類索引不穩(wěn)定是個大問題每次都要重新找很麻煩。有沒有不依賴索引的路徑有而且不止一條。一條路是走_(dá)_globals__直接摸。如果能訪問到某個函數(shù)的__globals__里面往往有__builtins__而__builtins__里有__import__通過__import__(os).popen(id).read()就能執(zhí)行命令。構(gòu)造上大概是{{.__class__.__mro__[1].__subclasses__()[索引].__init__.__globals__[__builtins__][__import__](os).popen(id).read()}}另一條路是走request對象如果 Flask 環(huán)境。Flask 會把request注入到模板上下文里通過request.application.__globals__可以摸到 Flask 應(yīng)用的全局命名空間里面通常有os。這條路徑在某些場景下比子類漫游更短{{request.application.__globals__.__builtins__.__import__(os).popen(id).read()}}還有一條路是走config對象。Flask 的config是Config類的實(shí)例通過config.__class__.__init__.__globals__也能摸到全局命名空間。這條路徑的好處是config通常是模板里直接可用的不需要先爬__class__。利用路徑起點(diǎn)優(yōu)點(diǎn)局限子類漫游.__class__.__mro__[1].__subclasses__()通用性強(qiáng)不依賴框架索引不穩(wěn)定需要現(xiàn)場定位request 對象request.application.__globals__路徑短Flask 環(huán)境穩(wěn)定僅限 Flask且 request 需在上下文config 對象config.__class__.__init__.__globals__起點(diǎn)直接可用依賴 Flask 配置對象存在內(nèi)置函數(shù)cycler.__init__.__globals__某些環(huán)境 cycler 可直接訪問依賴具體環(huán)境加載情況實(shí)際利用時我一般按request → config → 子類漫游的順序試哪個通就用哪個。因為前兩條路徑更短、更穩(wěn)定子類漫游作為兜底。4. 關(guān)鍵字被拉黑之后繞過手法的取舍與組合4.1 常見過濾規(guī)則與對應(yīng)繞過思路真實(shí)環(huán)境里直接把__class__、__globals__、os、popen這些詞寫進(jìn) payload大概率會被攔。過濾可能來自 WAF也可能來自應(yīng)用自身的黑名單。繞過的基本思路無非幾種字符串拼接、編碼轉(zhuǎn)換、屬性訪問的替代寫法、利用 Jinja2 的過濾器做變形。先看屬性訪問的替代寫法。Python 里訪問屬性除了obj.attr還可以用obj[attr]對于支持下標(biāo)訪問的對象或者getattr(obj, attr)。Jinja2 里attr過濾器也能達(dá)到類似效果obj|attr(__class__)。這幾種寫法在過濾規(guī)則眼里長得完全不一樣能繞過不少基于字符串匹配的規(guī)則。字符串拼接方面Jinja2 支持~做字符串拼接也支持。比如__cla~ss__拼出__class__。如果過濾規(guī)則只匹配完整的__class__這種拆分寫法就能過。更極端一點(diǎn)可以用request.args或者request.values從 URL 參數(shù)里取值把敏感字符串放在參數(shù)里payload 里只寫變量名這樣模板內(nèi)容里根本不出現(xiàn)敏感詞。編碼轉(zhuǎn)換方面Jinja2 的|string、|list、|join等過濾器可以做各種變形。比如把字符串轉(zhuǎn)成列表再拼回來或者用format過濾器做格式化。這些手法組合起來能構(gòu)造出人眼可讀但規(guī)則匹配不到的 payload。4.2 用 attr 過濾器重寫整條鏈把前面那條經(jīng)典鏈用attr過濾器重寫一遍大概是這個形態(tài){{|attr(__class__)|attr(__mro__)|attr(__getitem__)(1)|attr(__subclasses__)()|attr(__getitem__)(索引)|attr(__init__)|attr(__globals__)|attr(__getitem__)(os)|attr(popen)(id)|attr(read)()}}這條鏈里所有點(diǎn)號訪問都換成了attr過濾器所有下標(biāo)訪問都換成了__getitem__調(diào)用。雖然長了點(diǎn)但能繞過大量基于.和[]的簡單過濾。實(shí)測下來這種寫法在很多只做了基礎(chǔ)關(guān)鍵字匹配的環(huán)境里是能過的。不過要注意attr過濾器本身也可能被過濾。如果attr被拉黑可以試試|attr的等價寫法或者回到點(diǎn)號訪問但用字符串拼接繞過關(guān)鍵字。繞過這件事沒有銀彈得根據(jù)實(shí)際過濾規(guī)則來調(diào)整。4.3 無回顯場景下的判斷有些注入點(diǎn)執(zhí)行了但頁面不回顯結(jié)果這時候不能靠看返回值判斷成功與否。常見的手法是時間盲注或者帶外檢測。時間盲注的思路是讓 payload 執(zhí)行一個耗時操作比如sleep然后觀察響應(yīng)時間。Jinja2 里可以通過os.popen(sleep 5)之類的方式制造延遲。帶外檢測則是讓目標(biāo)發(fā)起一個外部請求把結(jié)果帶出來但這涉及具體環(huán)境配置不在本文展開。時間盲注的 payload 大概是{{.__class__.__mro__[1].__subclasses__()[索引].__init__.__globals__[os].popen(sleep 5).read()}}如果響應(yīng)時間明顯變長說明命令執(zhí)行成功了。這個方法的好處是不依賴回顯壞處是受網(wǎng)絡(luò)抖動影響大需要多測幾次取平均。注意時間盲注在共享環(huán)境或者有超時限制的環(huán)境里要謹(jǐn)慎使用sleep時間不宜過長否則可能觸發(fā)服務(wù)端超時或者被監(jiān)控系統(tǒng)標(biāo)記。5. 從攻擊視角反推防御方該怎么堵住這條路5.1 根因不在模板引擎在拼接方式聊完利用必須聊聊防御。因為 SSTI 的根因非常明確用戶輸入被當(dāng)成了模板源碼的一部分。所以最徹底的防御就是永遠(yuǎn)不要把用戶輸入拼進(jìn)模板字符串。該用變量傳參的地方就用變量傳參render_template_string(Hello {{ name }}, nameuser_input)這種寫法用戶輸入再惡意也只會被當(dāng)字符串渲染。這個道理說起來簡單但實(shí)際項目里踩坑的地方往往很隱蔽。比如某些動態(tài)模板功能為了讓用戶自定義通知內(nèi)容、郵件模板直接把用戶填的模板字符串拿去渲染。這種設(shè)計本身就把模板源碼的控制權(quán)交給了用戶等于把大門敞開。如果業(yè)務(wù)上確實(shí)需要用戶自定義模板那就必須上沙箱限制模板能訪問的對象和方法。5.2 沙箱不是萬能藥但能抬高門檻Jinja2 提供了SandboxedEnvironment它會限制模板能訪問的屬性和方法把__class__、__globals__這類魔術(shù)屬性擋在外面。啟用沙箱之后前面那些利用鏈基本都走不通了。但沙箱也不是絕對安全歷史上出現(xiàn)過不少沙箱逃逸的案例尤其是當(dāng)沙箱配置不當(dāng)、或者應(yīng)用自己往沙箱環(huán)境里注入了危險對象的時候。我的建議是沙箱要用但不能只依賴沙箱。沙箱是縱深防御的一層配合輸入校驗、最小權(quán)限、運(yùn)行時監(jiān)控才能形成比較完整的防護(hù)。另外沙箱的版本要及時更新已知的逃逸手法在 newer version 里通常會被修掉。5.3 排查清單審計時該盯哪些點(diǎn)如果你在做代碼審計想快速定位潛在的 SSTI 風(fēng)險點(diǎn)可以按下面這個清單過一遍排查項關(guān)注點(diǎn)風(fēng)險等級模板渲染函數(shù)render_template_string、from_string、Template()的直接調(diào)用高字符串拼接模板字符串是否由用戶輸入拼接而成高動態(tài)模板功能用戶自定義模板、郵件模板、通知模板高沙箱配置是否使用SandboxedEnvironment配置是否合理中上下文注入渲染時傳入的上下文是否包含敏感對象中錯誤處理模板報錯信息是否回顯給用戶低其中錯誤處理這一項容易被忽略。模板報錯信息里往往包含大量內(nèi)部細(xì)節(jié)比如類名、文件路徑、甚至部分源碼片段這些信息對攻擊者來說是寶貴的情報。生產(chǎn)環(huán)境應(yīng)該關(guān)閉詳細(xì)錯誤回顯只給用戶看通用錯誤頁。5.4 一個容易被忽視的細(xì)節(jié)模板繼承與 include除了直接的字符串拼接模板的include、extends、import這些指令如果路徑可控也可能引入非預(yù)期的模板內(nèi)容。雖然這不完全是 SSTI但同屬模板層面的注入風(fēng)險審計時值得一并關(guān)注。比如用戶能控制{% include user_input %}里的user_input就可能讀取到服務(wù)器上的其他模板文件造成信息泄露。這類問題的防御思路和 SSTI 類似路徑白名單、禁止用戶控制模板路徑、對 include 的目標(biāo)做嚴(yán)格校驗。6. 實(shí)操中積累的幾個判斷技巧聊了這么多原理和鏈路最后分享幾個我在實(shí)際操作中總結(jié)的小技巧都是文檔里不太會寫、但用起來很順手的東西。第一個技巧先看報錯再看回顯。很多注入點(diǎn)在 payload 語法錯誤時會拋出詳細(xì)的異常信息這些信息比正常回顯更有價值。比如 Jinja2 的TemplateSyntaxError會告訴你哪一行、哪個位置解析失敗UndefinedError會告訴你哪個變量未定義。通過這些報錯能快速判斷模板引擎類型、版本特征、以及上下文里有哪些變量可用。第二個技巧用{{self}}快速摸上下文。self在 Jinja2 模板里指向當(dāng)前模板對象打印它能帶出一堆內(nèi)部信息包括模板的上下文、環(huán)境配置等。這個 payload 很短不容易被過濾適合作為探測階段的信息收集器。第三個技巧子類索引不要硬編碼用循環(huán)或者篩選動態(tài)找。前面說過索引不穩(wěn)定與其每次手動找不如寫個 payload 動態(tài)定位。比如用selectattr或者列表推導(dǎo)Jinja2 支持有限的列表推導(dǎo)來篩選出目標(biāo)類。雖然寫起來復(fù)雜但一次寫好可以復(fù)用。第四個技巧注意 Python 版本差異。Python 2 和 Python 3 在__mro__、__subclasses__的行為上有差異字符串類型也不一樣strvsunicode。拿到一個注入點(diǎn)先判斷 Python 大版本再決定用哪套 payload。判斷方法很多比如{{.__class__}}在 Python 3 里返回class str在 Python 2 里可能是type str或type unicode。第五個技巧記錄環(huán)境指紋。每次遇到一個新的注入點(diǎn)把引擎類型、Python 版本、可用對象、過濾規(guī)則這些信息記下來。積累多了之后你會發(fā)現(xiàn)很多環(huán)境是相似的下次遇到同類目標(biāo)能少走很多彎路。這個習(xí)慣在做授權(quán)測試和代碼審計時特別有用。最后說一句實(shí)在話SSTI 這個方向原理不難難的是面對千變?nèi)f化的過濾規(guī)則和運(yùn)行環(huán)境時能快速判斷該走哪條路、該放棄哪條路。多動手、多記錄、多復(fù)盤比背一百條 payload 管用。