session引发的漏洞

0x01 前言

Session,中文意思是“会话”。对于“会话”我的理解是客户端与服务端间通信的一种方式,也可以简单的理解为一个用户从打开浏览器开始,访问一个web网站,点击某些超链接,访问某些服务端的资源,然后关闭浏览器的这一整个过程就是一次会话。

早期,客户端与服务端之间的每次信息传递都是独立的。这与HTTP协议的无状态性有关。用户发送的一个请求只是为了告诉服务端想访问的资源,然后服务端将用户要的资源返回回去,就这么简单。后来,随着人们需求的增长,网站的所有者希望对每个用户提供个性的、精细化的服务,最初的静态资源已经无法满足如“用户机制”、“个性推荐”等多样的需求了。

对于无状态的HTTP协议,人们提出来两种解决方案,分别是Cookie和Session。下面讲一下Cookie和Session的区别及联系。

Cookie机制:

一般来说,Cookie分发是通过扩展HTTP协议来实现的,服务器通过在HTTP的响应头中加上一行特殊的指示以提示浏览器按照指示生成相应的Cookie。然而纯粹的客户端脚本如JavaScript也可以生成Cookie。Cookie相当于由用户自己保存的一张纸,上面记载着用户的信息。比如用户名、密码等等。Cookie一般是由浏览器在后台自动发送给服务器的。浏览器会检查所有的Cookie,当某个Cookie的作用域大于或等于所要访问的资源的位置时,浏览器就会把这个Cookie附在请求资源的HTTP请求头上发送给服务器。可以说,这种方式是客户端(用户)在维持状态。

Session机制:

客户端请求服务端时,服务端会为客户端创建一个Session,并检查请求中是否包含Session ID。形象的来说,一个Session相当于是一张会员卡,上面除了一个卡号其他什么都没有。这个卡号就是Session ID。当存在Session ID时就检索出相应的Session。不存在则创建一个Session并生成一个Session ID。Session ID的值应该是一个既不会重复,又不容易被找到规律以仿造的字符串。当一个用户拿着这张“会员卡”访问一个网站时,用户在网站上的有关信息和操作都会被记录在服务端的这张会员卡对应的卡号下。很明显,这种方式就是服务端在维持状态。而Session机制和Cookie机制又有什么联系呢?虽然Session机制中用户的状态由服务端来维持,但是,Session中的Session ID还是要用户自己来保管的,而一般来说,Session ID则以Cookie的形式保存在客户端。但这种方式有一个弊端就是如果客户端禁用了Cookie,那么Session机制将无法正常工作。解决这个问题有两种方法

一种是URL重写,简单的说就是将Session ID作为URL的附加信息或参数,通过URL来传递。

另一种是将Session ID写在表单(Form)的隐藏域中,在表单提交时将Session ID一起提交上去。

0x02 session 逻辑漏洞

2.1 session 覆盖

浏览器打开两个重置密码的网页

第一个账号

第二个账号:

2.2 商城任意用户登录越权

通过对App的我的-购买记录-实物订单功能进行测试,App首先通过/iget/check获取用于访问实物订单的JWT会话签名,但是服务端并没有校验sign参数签名的正确性,导致可以直接通过更改请求会话签名中的userId参数,从而获取任意用户的会话签名信息,根据签名去/api/order/gelist路由获取用户的事物订单详情,存在越权漏洞,还可以通过此漏洞获取任意或者更改删除任意用户地址,发票等功能,订单详情中泄露了用户的收件地址,电话,订单支付方式等隐私信息。

使用phpsessid登录,此处可以编写脚本利用

防御:域名上添加签名校验,防止获取其他用户会话信息

0x03 session 框架漏洞

3.1 flask session

将session存储在客户端cookie中,最重要的就是解决session不能被篡改的问题。

flask处理session:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
class SecureCookieSessionInterface(SessionInterface):
"""The default session interface that stores sessions in signed cookies
through the :mod:`itsdangerous` module.
"""
#: the salt that should be applied on top of the secret key for the
#: signing of cookie based sessions.
salt = 'cookie-session'
#: the hash function to use for the signature. The default is sha1
digest_method = staticmethod(hashlib.sha1)
#: the name of the itsdangerous supported key derivation. The default
#: is hmac.
key_derivation = 'hmac'
#: A python serializer for the payload. The default is a compact
#: JSON derived serializer with support for some extra Python types
#: such as datetime objects or tuples.
serializer = session_json_serializer
session_class = SecureCookieSession

def get_signing_serializer(self, app):
if not app.secret_key:
return None
signer_kwargs = dict(
key_derivation=self.key_derivation,
digest_method=self.digest_method
)
return URLSafeTimedSerializer(app.secret_key, salt=self.salt,
serializer=self.serializer,
signer_kwargs=signer_kwargs)

def open_session(self, app, request):
s = self.get_signing_serializer(app)
if s is None:
return None
val = request.cookies.get(app.session_cookie_name)
if not val:
return self.session_class()
max_age = total_seconds(app.permanent_session_lifetime)
try:
data = s.loads(val, max_age=max_age)
return self.session_class(data)
except BadSignature:
return self.session_class()

def save_session(self, app, session, response):
domain = self.get_cookie_domain(app)
path = self.get_cookie_path(app)
# Delete case. If there is no session we bail early.
# If the session was modified to be empty we remove the
# whole cookie.
if not session:
if session.modified:
response.delete_cookie(app.session_cookie_name,
domain=domain, path=path)
return
# Modification case. There are upsides and downsides to
# emitting a set-cookie header each request. The behavior
# is controlled by the :meth:`should_set_cookie` method
# which performs a quick check to figure out if the cookie
# should be set or not. This is controlled by the
# SESSION_REFRESH_EACH_REQUEST config flag as well as
# the permanent flag on the session itself.
if not self.should_set_cookie(app, session):
return
httponly = self.get_cookie_httponly(app)
secure = self.get_cookie_secure(app)
expires = self.get_expiration_time(app, session)
val = self.get_signing_serializer(app).dumps(dict(session))
response.set_cookie(app.session_cookie_name, val,
expires=expires, httponly=httponly,
domain=domain, path=path, secure=secure)

主要看最后两行代码,新建了URLSafeTimedSerializer类 ,用它的dumps方法将类型为字典的session对象序列化成字符串,然后用response.set_cookie将最后的内容保存在cookie中。

那么我们可以看一下URLSafeTimedSerializer是做什么的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
class Signer(object):
# ...
def sign(self, value):
"""Signs the given string."""
return value + want_bytes(self.sep) + self.get_signature(value)

def get_signature(self, value):
"""Returns the signature for the given value"""
value = want_bytes(value)
key = self.derive_key()
sig = self.algorithm.get_signature(key, value)
return base64_encode(sig)


class Serializer(object):
default_serializer = json
default_signer = Signer
# ....
def dumps(self, obj, salt=None):
"""Returns a signed string serialized with the internal serializer.
The return value can be either a byte or unicode string depending
on the format of the internal serializer.
"""
payload = want_bytes(self.dump_payload(obj))
rv = self.make_signer(salt).sign(payload)
if self.is_text_serializer:
rv = rv.decode('utf-8')
return rv

def dump_payload(self, obj):
"""Dumps the encoded object. The return value is always a
bytestring. If the internal serializer is text based the value
will automatically be encoded to utf-8.
"""
return want_bytes(self.serializer.dumps(obj))


class URLSafeSerializerMixin(object):
"""Mixed in with a regular serializer it will attempt to zlib compress
the string to make it shorter if necessary. It will also base64 encode
the string so that it can safely be placed in a URL.
"""
def load_payload(self, payload):
decompress = False
if payload.startswith(b'.'):
payload = payload[1:]
decompress = True
try:
json = base64_decode(payload)
except Exception as e:
raise BadPayload('Could not base64 decode the payload because of '
'an exception', original_error=e)
if decompress:
try:
json = zlib.decompress(json)
except Exception as e:
raise BadPayload('Could not zlib decompress the payload before '
'decoding the payload', original_error=e)
return super(URLSafeSerializerMixin, self).load_payload(json)

def dump_payload(self, obj):
json = super(URLSafeSerializerMixin, self).dump_payload(obj)
is_compressed = False
compressed = zlib.compress(json)
if len(compressed) < (len(json) - 1):
json = compressed
is_compressed = True
base64d = base64_encode(json)
if is_compressed:
base64d = b'.' + base64d
return base64d


class URLSafeTimedSerializer(URLSafeSerializerMixin, TimedSerializer):
"""Works like :class:`TimedSerializer` but dumps and loads into a URL
safe string consisting of the upper and lowercase character of the
alphabet as well as ``'_'``, ``'-'`` and ``'.'``.
"""
default_serializer = compact_json

主要关注dump_payloaddumps,这是序列化session的主要过程。

可见,序列化的操作分如下几步:

  1. json.dumps 将对象转换成json字符串,作为数据
  2. 如果数据压缩后长度更短,则用zlib库进行压缩
  3. 将数据用base64编码
  4. 通过hmac算法计算数据的签名,将签名附在数据后,用“.”分割

第4步就解决了用户篡改session的问题,因为在不知道secret_key的情况下,是无法伪造签名的。

最后,我们在cookie中就能看到设置好的session了:

693b25ce-0a26-43b2-b8c2-80e8786cc9b8.png

注意到,在第4步中,flask仅仅对数据进行了签名。众所周知的是,签名的作用是防篡改,而无法防止被读取。而flask并没有提供加密操作,所以其session的全部内容都是可以在客户端读取的,这就可能造成一些安全问题。

3.2 flask客户端session导致敏感信息泄露

我曾遇到过一个案例,目标是flask开发的一个简历管理系统,在测试其找回密码功能的时候,我收到了服务端设置的session。

flask是一个客户端session,所以看目标为flask的站点的时候,我习惯性地去解密其session。编写如下代码解密session:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/usr/bin/env python3
import sys
import zlib
from base64 import b64decode
from flask.sessions import session_json_serializer
from itsdangerous import base64_decode

def decryption(payload):
payload, sig = payload.rsplit(b'.', 1)
payload, timestamp = payload.rsplit(b'.', 1)

decompress = False
if payload.startswith(b'.'):
payload = payload[1:]
decompress = True

try:
payload = base64_decode(payload)
except Exception as e:
raise Exception('Could not base64 decode the payload because of '
'an exception')

if decompress:
try:
payload = zlib.decompress(payload)
except Exception as e:
raise Exception('Could not zlib decompress the payload before '
'decoding the payload')

return session_json_serializer.loads(payload)

if __name__ == '__main__':
print(decryption(sys.argv[1].encode()))

例如,我解密3.1中演示的session:

789edad0-8216-43d3-b6eb-93557e03b63d.png

通过解密目标站点的session,我发现其设置了一个名为token、值是一串md5的键。猜测其为找回密码的认证,将其替换到找回密码链接的token中,果然能够进入修改密码页面。通过这个过程,我就能修改任意用户密码了。

这是一个比较典型的安全问题,目标网站通过session来储存随机token并认证用户是否真的在邮箱收到了这个token。但因为flask的session是存储在cookie中且仅签名而未加密,所以我们就可以直接读取这个token了。

3.2 flask验证码绕过漏洞

这是客户端session的另一个常见漏洞场景。

我们用一个实际例子认识这一点:https://github.com/shonenada/flask-captcha 。这是一个为flask提供验证码的项目,我们看到其中的view文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import random
try:
from cStringIO import StringIO
except ImportError:
from io import BytesIO as StringIO

from flask import Blueprint, make_response, current_app, session
from wheezy.captcha.image import captcha
from wheezy.captcha.image import background
from wheezy.captcha.image import curve
from wheezy.captcha.image import noise
from wheezy.captcha.image import smooth
from wheezy.captcha.image import text
from wheezy.captcha.image import offset
from wheezy.captcha.image import rotate
from wheezy.captcha.image import warp


captcha_bp = Blueprint('captcha', __name__)


def sample_chars():
characters = current_app.config['CAPTCHA_CHARACTERS']
char_length = current_app.config['CAPTCHA_CHARS_LENGTH']
captcha_code = random.sample(characters, char_length)
return captcha_code

@captcha_bp.route('/captcha', endpoint="captcha")
def captcha_view():
out = StringIO()
captcha_image = captcha(drawings=[
background(),
text(fonts=current_app.config['CAPTCHA_FONTS'],
drawings=[warp(), rotate(), offset()]),
curve(),
noise(),
smooth(),
])
captcha_code = ''.join(sample_chars())
imgfile = captcha_image(captcha_code)
session['captcha'] = captcha_code
imgfile.save(out, 'PNG')
out.seek(0)
response = make_response(out.read())
response.content_type = 'image/png'
return response

可见,其生成验证码后,就存储在session中了:session['captcha'] = captcha_code

我们用浏览器访问/captcha,即可得到生成好的验证码图片,此时复制保存在cookie中的session值,用0x03中提供的脚本进行解码:

cf1b824b-9b61-4770-9224-1421e6fad65c.png

可见,我成功获取了验证码的值,进而可以绕过验证码的判断。

这也是客户端session的一种错误使用方法。

3.3 CodeIgniter 2.1.4 session伪造及对象注入漏洞

Codeigniter 2的session也储存在session中,默认名为ci_session,默认值如下:

d2bd8335-a3e2-4f72-858f-4e93140dee6d.png

可见,session数据被用PHP自带的serialize函数进行序列化,并签名后作为ci_session的值。原理上和flask如出一辙,我就不重述了。但好在codeigniter2支持对session进行加密,只需在配置文件中设置$config['sess_encrypt_cookie'] = TRUE;即可。

在CI2.1.4及以前的版本中,存在一个弱加密漏洞( https://www.dionach.com/blog/codeigniter-session-decoding-vulnerability ),如果目标环境中没有安装Mcrypt扩展,则CI会使用一个相对比较弱的加密方式来处理session:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php
function _xor_encode($string, $key)
{
$rand = '';
while (strlen($rand) < 32)
{
$rand .= mt_rand(0, mt_getrandmax());
}
$rand = $this->hash($rand);
$enc = '';
for ($i = 0; $i < strlen($string); $i++)
{
$enc .= substr($rand, ($i % strlen($rand)), 1).(substr($rand, ($i % strlen($rand)), 1) ^ substr($string, $i, 1));
}
return $this->_xor_merge($enc, $key);
}

function _xor_merge($string, $key)
{
$hash = $this->hash($key);
$str = '';
for ($i = 0; $i < strlen($string); $i++)
{
$str .= substr($string, $i, 1) ^ substr($hash, ($i % strlen($hash)), 1);
}
return $str;
}

其中用到了mt_rand、异或等存在大量缺陷的方法。我们通过几个简单的脚本( https://github.com/Dionach/CodeIgniterXor ),即可在4秒到4分钟的时间,破解CI2的密钥。

获取到了密钥,我们即可篡改任意session,并自己签名及加密,最后伪造任意用户,注入任意对象,甚至通过反序列化操作造成更大的危害。

3.4 总结

我以三个案例来说明了客户端session的安全问题。

上述三个问题,如果session是储存在服务器文件或数据库中,则不会出现。当然,考虑到flask和ci都是非常轻量的web框架,很可能运行在无法操作文件系统或没有数据库的服务器上,所以客户端session是无法避免的。

除此之外,我还能想到其他客户端session可能存在的安全隐患:

  1. 签名使用hash函数而非hmac函数,导致利用hash长度扩展攻击来伪造session
  2. 任意文件读取导致密钥泄露,进一步造成身份伪造漏洞或反序列化漏洞( http://www.loner.fm/drops/#!/drops/227.Codeigniter%20%E5%88%A9%E7%94%A8%E5%8A%A0%E5%AF%86Key%EF%BC%88%E5%AF%86%E9%92%A5%EF%BC%89%E7%9A%84%E5%AF%B9%E8%B1%A1%E6%B3%A8%E5%85%A5%E6%BC%8F%E6%B4%9E
  3. 如果客户端session仅加密未签名,利用CBC字节翻转攻击,我们可以修改加密session中某部分数据,来达到身份伪造的目的

上面说的几点,各位CTF出题人可以拿去做文章啦~嘿嘿。

相对的,作为一个开发者,如果我们使用的web框架或web语言的session是存储在客户端中,那就必须牢记下面几点:

  1. 没有加密时,用户可以看到完整的session对象
  2. 加密/签名不完善或密钥泄露的情况下,用户可以修改任意session
  3. 使用强健的加密及签名算法,而不是自己造(反例discuz)
-------------本文结束感谢您的阅读-------------