CVE-2020-7245 账号接管漏洞

漏洞描述

在CTFd v2.0.0-v2.2.2的注册过程中,错误的用户名验证会允许攻击者接管任意帐户,前提是用户名已知并且在CTFd实例上启用了电子邮件。要利用此漏洞,必须使用与受害者的用户名相同的用户名进行注册,但在用户名之前和/或之后插入空格。这将使用与受害者相同的用户名注册该帐户。在为新帐户启动密码重置后,由于用户名冲突,CTFd将重置受害者的帐户密码。

漏洞分析

该漏洞主要由注册部分的逻辑产生的
查看源码https://github.com/CTFd/CTFd/blob/fe85fdf1e5561e9f04c1c80c72e894dcd7ea3ad7/CTFd/auth.py#L159

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
80
81
82
83
84
85
86
87
88
89
90
91
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()
emails = (
Users.query.add_columns("email", "id")
.filter_by(email=email_address)
.first()
)
pass_short = len(password.strip()) == 0
pass_long = len(password) > 128
valid_email = validators.validate_email(request.form["email"])
team_name_email_check = validators.validate_email(name)

if not valid_email:
errors.append("Please enter a valid email address")
if email.check_email_is_whitelisted(email_address) is False:
errors.append(
"Only email addresses under {domains} may register".format(
domains=get_config("domain_whitelist")
)
)
if names:
errors.append("That user name is already taken")
if team_name_email_check is True:
errors.append("Your user name cannot be an email address")
if emails:
errors.append("That email has already been used")
if pass_short:
errors.append("Pick a longer password")
if pass_long:
errors.append("Pick a shorter password")
if name_len:
errors.append("Pick a longer user name")

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)

if config.can_send_mail() and get_config(
"verify_emails"
): # Confirming users is enabled and we can send email.
log(
"registrations",
format="[{date}] {ip} - {name} registered (UNCONFIRMED) with {email}",
)
email.verify_email_address(user.email)
db.session.close()
return redirect(url_for("auth.confirm"))
else: # Don't care about confirming users
if (
config.can_send_mail()
): # We want to notify the user that they have registered.
email.sendmail(
request.form["email"],
"You've successfully registered for {}".format(
get_config("ctf_name")
),
)

log("registrations", "[{date}] {ip} - {name} registered with {email}")
db.session.close()

if is_teams_mode():
return redirect(url_for("teams.private"))

return redirect(url_for("challenges.listing"))
else:
return render_template("register.html", errors=errors)

程序在判断用户名是否重复时,使用的用户名是用户直接Post过来的,而在存入数据库中的时候进行了strip操作.所以我们只要注册一个前面或后面加空格的admin账号即可绕过用户名重复的限制.

再来看一下找回密码的逻辑

https://github.com/CTFd/CTFd/blob/fe85fdf1e5561e9f04c1c80c72e894dcd7ea3ad7/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
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
@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"))

if request.method == "POST":
email_address = request.form["email"].strip()
team = Users.query.filter_by(email=email_address).first()

get_errors()

if config.can_send_mail() is False:
return render_template(
"reset_password.html",
errors=["Email could not be sent due to server misconfiguration"],
)

if not team:
return render_template(
"reset_password.html",
errors=[
"If that account exists you will receive an email, please check your inbox"
],
)

email.forgot_password(email_address, team.name)

return render_template(
"reset_password.html",
errors=[
"If that account exists you will receive an email, please check your inbox"
],
)
return render_template("reset_password.html")

程序会根据你输入的邮箱发送相关账号更新信息到你邮箱中,而当你更新你的密码时,会从你访问的url获取data(你的假账号),并将它反序列化,根据序列化出来的对象的name值更新对应的账号(被覆盖的账号)密码

利用方式

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

参考资料

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