Fork me on GitHub

CVE-2020-7245 CTFd v2.0.0 - v2.2.2 account takeover分析

最近注意到twitter上有大牛提到CTFd最新修复的一个账户接管漏洞,且放出了commit id,这里分析一下这个漏洞的利用方式。

这个漏洞主要出现在注册的逻辑中,跟进注册账号的逻辑https://github.com/CTFd/CTFd/blob/2.2.0-dev/CTFd/auth.py#L156

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
def register():
errors = get_errors()
if request.method == "POST":
name = request.form["name"]
email_address = request.form["email"]
password = request.form["password"]

name_len = len(name) == 0
names = Users.query.add_columns("name", "id").filter_by(name=name).first()

# 省略一部分代码

if names:
errors.append("That user name is already taken")

# 省略一部分代码
if len(errors) > 0:
return render_template(
"register.html",
errors=errors,
name=request.form["name"],
email=request.form["email"],
password=request.form["password"],
)
else:
with app.app_context():
user = Users(
name=name.strip(),
email=email_address.lower(),
password=password.strip(),
)
db.session.add(user)
db.session.commit()
db.session.flush()

login_user(user)

可以看到在判断用户名是否重复时,使用的用户名是从post数据中直接得到的name值,然而入库时却将这个name值做了strip处理去掉首尾的空字符。因此我们只要注册一个首位加空格的用户名即可绕过用户名不能重复的限制。

我们再来看一下找回密码的逻辑https://github.com/CTFd/CTFd/blob/2.2.0-dev/CTFd/auth.py#L95

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
@auth.route("/reset_password", methods=["POST", "GET"])
@auth.route("/reset_password/<data>", methods=["POST", "GET"])
@ratelimit(method="POST", limit=10, interval=60)
def reset_password(data=None):
if data is not None:
try:
name = unserialize(data, max_age=1800)
except (BadTimeSignature, SignatureExpired):
return render_template(
"reset_password.html", errors=["Your link has expired"]
)
except (BadSignature, TypeError, base64.binascii.Error):
return render_template(
"reset_password.html", errors=["Your reset token is invalid"]
)

if request.method == "GET":
return render_template("reset_password.html", mode="set")
if request.method == "POST":
user = Users.query.filter_by(name=name).first_or_404()
user.password = request.form["password"].strip()
db.session.commit()
log(
"logins",
format="[{date}] {ip} - successful password reset for {name}",
name=name,
)
db.session.close()
return redirect(url_for("auth.login"))

找回密码时从链接参数中取data值,将其反序列化后可获得用户名,即可更改任意用户的密码。我们再看一下这个链接是怎么得到的,也就是data是怎么生成的,在https://github.com/CTFd/CTFd/blob/eec535b739a43a6e37735e4251244974185f6634/CTFd/utils/email/__init__.py#L19

1
2
3
4
5
6
7
8
9
def forgot_password(email, team_name):
token = serialize(team_name)
text = """Did you initiate a password reset? Click the following link to reset your password:
{0}/{1}
""".format(
url_for("auth.reset_password", _external=True), token
)

return sendmail(email, text)

可以看到这个token是直接将team_name做一次序列化处理后拼接到url中发送到用户的邮箱。查看serialize方法的实现https://github.com/CTFd/CTFd/blob/eec535b739a43a6e37735e4251244974185f6634/CTFd/utils/security/signing.py#L10

1
2
3
4
5
def serialize(data, secret=None):
if secret is None:
secret = current_app.config["SECRET_KEY"]
s = URLSafeTimedSerializer(secret)
return s.dumps(data)

正是利用flask自身的类似客户端cookie的序列化方式做了一个加密。

结合上面的注册以及找回密码的流程,我们的攻击方式便很明显了:

  1. 利用添加空格绕过限制来注册一个与受害者用户名相同的账号
  2. 生成忘记密码链接发送到自己的邮箱
  3. 将自己的账号的用户名改成与被攻击者不相同的用户名
  4. 用邮箱中收到的链接更改密码即可。

这个漏洞分析比较简单,算是实战account takeover代码审计的一个不错的例子。