几星期之前,我和我的一个小伙伴正在讨论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会使用名为“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万条记录
那么我们怎么才能绕过认证呢?我们来看如下示例(请复制前文代码,以便按步骤执行):
pip install flask
安装Flask(可以在虚拟环境中安装)在进一步操作前,我们需要启动Flask,命令如下:
$ python server.py
为了获取会话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。
图7. 构造新的会话
如果我们现在向同一个服务器发起请求,服务器应该会接受我们构造的cookie,因为这个cookie与服务器的秘钥匹配,这样服务器会认为我们已经成功登录。
图8. 使用我们构造的cookie来绕过服务端认证
这里指出的是,虽然我们可以修改会话,但并不意味着我们可以立刻绕过身份认证机制。并非所有的系统都使用同样的方法构建,我们可能需要进一步研究,确定具体情况,以及是否能在实际环境中使用这种技术。
由于在分析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()