浅谈Flask cookie与密钥的安全性

 

一、前言

几星期之前,我和我的一个小伙伴正在讨论web框架,小伙伴声称他构造了一个“绝对无法绕过的”登录表单。征得小伙伴同意后,他把相关代码发给我审阅。

正如我所料,他说的有一定道理,代码看上去的确非常安全,不存在SQL注入XSS漏洞,他甚至限制了访问速率并会审计日志。看来如果不知道密码,我们似乎无法绕过登录表单。

这种实现非常完美,直到我注意到他使用的密钥(secret key)为“CHANGEME”。

 

二、利用人为错误

我的小伙伴没有意识到一点:使用脆弱的密钥会比在客户端存储当前用户的状态其实更加危险。考虑如下一个示例程序(该程序完全摘抄自小伙伴提供的代码):

# Requirements: Flask
from flask import Flask, session


app = Flask(__name__)
app.config['SECRET_KEY'] = 'CHANGEME'


@app.route('/')
def index():
    if 'logged_in' not in session:
        session['logged_in'] = False

    if session['logged_in']:
        return '<h1>You are logged in!</h1>'
    else:
        return '<h1>Access Denied</h1>', 403


if __name__ == '__main__':
    app.run()

乍看之下,代码似乎有点难以理解,我甚至都想直接删除登录表单(因为我们永远无法猜到正确的密码)。那么我们如何才能看到“You are logged in!”消息呢?

 

三、Flask的会话管理

默认情况下,Flask会使用名为“signed cookies”的一种机制,这是在客户端(而非服务端)存储当前会话(session)数据的一种简单方式,使其(从理论上)无法被篡改。

然而这种方法存在一些缺点,比如cookie值并非经过加密处理,而是经过签名处理。这意味着我们不需要密钥也能读取会话内容。

此外,我还与许多Python开发人员交流过,大部分人都认为客户端无法读取会话数据,因为用来签名cookie的代码为SecureCookieSessionInterface,这个函数名也给他们带来不可靠的安全感。我们来考虑如下会话:

图1. 一个“安全的”cookie可以切分成若干部分

会话数据

会话数据实际上就是会话的内容,虽然第一眼看上去这种数据很难理解,但其实这只是经过Base64编码的一个字符串。如果我们使用itsdangerous的base64解码器对其进行解码,可以得到如下输出:

图2. 服务端设置的会话数据

时间戳

时间戳可以告诉服务端数据最后一次更新的时间。根据我们所使用的itsdangerous的具体版本,该时间戳可能对应当前的Unix时间戳、或者当前当前Unix时间戳减去epoch时间(即1970-01-01 00:00:00 UTC)所对应的值(由于之前存在一个bug,我们无法设置早于2011年的时间,因此存在两种不同的时间戳版本,参考此处资料)。

如果时间戳已超过31天,那么会话就会被打上过期标记,变成无效会话。

加密哈希

这就是让cookie变得“安全”的字段。服务器向我们发送最新的会话数据之前,会结合我们的会话数据、当前时间戳以及服务器的私钥来计算sha1哈希

每当服务器再次看到该会话,就会解析这几部分字段,然后使用同样的方法验证这些数据。如果哈希与给定的数据不匹配,那么表明数据已被篡改,因此服务端会将该会话当成无效会话。

这意味着如果我们的私钥非常容易猜测,或者已对外公开,那么攻击者可以不费吹灰之力修改会话的内容。我们可以来看一下网上已公开的私钥情况,在GitHUb上搜索secret_key,可以看到如下结果:

图3. 在GitHub上搜索secret_key可以返回将近240万条记录

 

四、绕过认证

那么我们怎么才能绕过认证呢?我们来看如下示例(请复制前文代码,以便按步骤执行):

前提条件

  • 安装Python解释器(我使用的是Python 3.6版)
  • 使用pip install flask安装Flask(可以在虚拟环境中安装)

在进一步操作前,我们需要启动Flask,命令如下:

$ python server.py

获取会话cookie

为了获取会话cookie,我们需要先从服务器探测出可能使用的cookie格式。这里我使用curl发起请求,并带上-v选项以获得verbose输出信息(可以打印出请求头部),大家也可以直接访问服务器web页面,再配合浏览器插件(如EditThisCookie)来获取cookie的内容:

图4. 服务端返回会话cookie

需要注意的是,并非所有服务器都会立刻返回一个会话,有些服务器只会在出现错误时返回会话,而其他服务器只会在用户登录后返回会话。我们需要根据具体情况具体分析。为了演示方便,这里我们的示例程序无论何时都会设置会话信息。

创建字典

虽然我们可以组合各种可能的字母、数字以及符号来暴力破解,但更好的办法是创建一个字典集(wordlist),其中包含开发者已经公开过的秘钥。

为建立字典,我首先想到的是两个来源:GitHub(如前文所示)以及StackOverflow(我们可以通过archive.org来下载该网站上曾经发表过的每条评论、文章等数据)。

对于GitHub,我简单编写了一个一次性脚本,该脚本会搜索secret_key,然后尽可能多地下载commit以及文件。对于StackOverflow,我尝试遍历每个可能的文本,然后使用如下正则表达式来匹配秘钥值:

图5. 用来识别秘钥的正则表达式

我花了一周时间在我的VPS上爬取GitHub数据,结合我从StackOverflow上提取的每篇文章、评论后,我最终得到了37069条不同的秘钥值。

破解签名

现在我们手头上已经有一份字典,也知道cookie及部分Flask会话管理代码,接下来我们可以使用每个秘钥值来匹配cookie,判断签名是否有效。如果没有出现任何错误,则表明我们获得了有效的签名,这意味着我们已经找到了服务器的私钥!

图6. 会话破解示例应用

制作会话cookie

如果脚本运行成功,那么现在我们已经获得了服务器的私钥。现在我们可以使用同样的代码,但此时我们并不“加载”已有的会话,而是“转储”会话,使用任意数据来创建cookie。

图7. 构造新的会话

如果我们现在向同一个服务器发起请求,服务器应该会接受我们构造的cookie,因为这个cookie与服务器的秘钥匹配,这样服务器会认为我们已经成功登录。

图8. 使用我们构造的cookie来绕过服务端认证

这里指出的是,虽然我们可以修改会话,但并不意味着我们可以立刻绕过身份认证机制。并非所有的系统都使用同样的方法构建,我们可能需要进一步研究,确定具体情况,以及是否能在实际环境中使用这种技术。

 

五、Flask-Unsign

由于在分析Flask对会话的处理过程中我做了不少操作,因此我决定将代码集成到一款方便使用的命令行工具,使大家能够扫描自己的服务器是否存在类似问题。

大家可以在命令行中使用pip来安装该工具,命令如下:

$ pip install flask-unsign[wordlist]

如果大家不需要庞大的字典文件,只想使用代码,也可以忽略其中的[wordlist]命令。

$ pip install flask-unsign

大家也可以访问GitHub阅读源代码。

使用方法

Flask-Unsign主要有3种使用场景:对cookie的解码、签名以及解除签名(破解),代码支持HTTP协议,因此用户无需打开浏览器:

图9. 使用Flask-Unsign破解并构造会话

 

六、情况统计

现在可能有人会有问题:实际环境中这种攻击方法是否有效?为了测试这一点,我和Rik van Duijn(Twitter)利用Shodan简单统计了一下有多少设备可能在使用Flask框架,结果如下:

图10. 大约有88,000台服务器在使用Flask

然后我们将目标限定在立刻返回会话cookie的那些服务器(因为想查看完整的Shodan数据较为昂贵,并且爬取每台主机然后定位会话cookie也是动作较大的一种行为,可能会让我的路由器不堪重负,我的ISP可能也会介入,询问我的具体动机)。

图11. 结果缩小到1,500台服务器

下载搜索结果后,我开始分析我能利用之前的字典成功破解多少个会话。

服务器正在运行Werkzeug并不意味着它们使用的是Flask框架。此外有些应用的信息会被其他web服务器覆盖(如Nginx)、有些服务器不会立刻设置cookie、有些服务器前端存在防火墙,这些我们都没有考虑,因此下面给出的统计数据并不全面。

图12. 所有有效会话中有超过28%会被成功破解

最初我在MacBook上暴力破解会话,但很快我就意识到MacBook对多线程Python的处理并不高效,因此我切换到另一台(性能稍好的)游戏主机来运行脚本,整个任务大概花了20分钟。

剔除非签名的cookie后(大部分为服务端cookie,或者与Flask使用相同的命名约定及基本代码的其他框架),我得到了1242个有效的会话。将这些会话输入Flask-Unsign进行处理后,有352个会话被成功破解,破解率已经超过了28%。在这352个会话中,我只用到了78个不同的秘钥值。

图13. 10大常用的秘钥

 

七、缓解措施

有各种方法可以避免这种问题。首先也是最明显的办法就是保证我们所使用的秘钥的安全。此外,我们还可以考虑如下措施:

使用随机化秘钥

不使用容易被猜出的秘钥,而使用完全随机的值。理想情况下,每次启动应用时我们都应该将秘钥设置为随机值,但这样对用户体验可能不是很好,因此每次重启服务时用户会话都会过期。

最实用的解决方案就是生成一个UUID值。我们可以在类Unix系统上使用uuid或者uuidgen命令来完成该任务,或者在安装了Python环境的主机上执行如下命令:

$ python -c 'import uuid; print(uuid.uuid4());'

使用服务端会话

除了避免攻击者猜出秘钥之外,我们也可以使用服务端会话来避免攻击者查看会话的内容,此时攻击者只能得到一个唯一的token值。

对于Flask,我们可以安装Flask-Session包,然后在构建应用时进行初始化。

 

示例应用

Flask示例应用如下所示,其中使用了前文提到了缓解措施,可以加强会话的鲁棒性。

# Requirements: Flask, Flask-Session
import os
from flask import Flask, session
from flask_session import Session


app = Flask(__name__)

app.config['SECRET_KEY'] = os.urandom(64)
app.config['SESSION_TYPE'] = 'filesystem'

Session(app)


@app.route('/')
def index():
    if 'logged_in' not in session:
        session['logged_in'] = False

    if session['logged_in']:
        return '<h1>You are logged in!</h1>'
    else:
        return '<h1>Access Denied</h1>', 403


if __name__ == '__main__':
    app.run()
(完)