CVE-2020-7245脆弱性分析
前書き
この脆弱性は、CTFdアカウント乗っ取りの脆弱性です。パスワードの登録と変更には論理的な脆弱性があり、アカウントパスワードの変更につながります。
影響を受けるバージョン:v2.0.0-2.2.2
脆弱性分析
まず、ユーザーレジストリを見つけます:/CTFd/auto.py
@auth.route("/register", methods=["POST", "GET"])
@check_registration_visibility
@ratelimit(method="POST", limit=10, interval=5)
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)
上記のコードのほとんどは入力検出であり、重要な部分が抽出されます。
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 len(errors) > 0: #检测出错
'''注册账户密码插入数据库'''
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"))
上部の上部は、ユーザー入力を受け入れます。
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)
キーはここにあります:
names = Users.query.add_columns("name", "id").filter_by(name=name).first()
ユーザーが登録されているかどうかを判断する際には、名前が直接使用されます。つまり、ユーザーが入力したアカウント名であり、フィルタリングは行われません。
後半では、登録が成功したら、アカウント、パスワード、および電子メールをデータベースに挿入します。
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()
ただし、ここでは、スペースを削除する操作は、ユーザーが入力したアカウントで実行されます。(つまり、アカウントm1sn0wがデータベースに存在するが、登録時に入力したアカウント名:space m1sn0wの場合、登録時にアカウントのプロンプトは表示されませんが、ユーザー名m1sn0wがデータベースに挿入されます。で、つまり、データベースに同じ名前のユーザーがいます)
次は2番目の使用ポイント(パスワードの変更)です:メインコードを抽出します
@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"))
パスワードを変更すると、対応するメールボックスにメールが送信され、クリックするとパスワードを変更できることがわかっています。(上記のデータ値は、指定されたメールボックスに送信されたURLの後の値の文字列です)
次に、データ値が何であるかを確認します:/ CTFd / utils / email / __ init__.py
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)
ご覧のとおり、ユーザー名をシリアル化した後、対応するURLに接続され、メールボックスに送信されます。(前回の分析では、データベースに同じ名前のアカウントが2つあることがわかっているため、パスワードを変更すると、最初のユーザーのパスワードが変更されます)
(現在のユーザーを別のユーザー名に変更する必要があるとの記事もありますが、必要ないようです)
if request.method == "POST":
user = Users.query.filter_by(name=name).first_or_404()
user.password = request.form["password"].strip()
db.session.commit()
ここで取り出すユーザーは、最初のユーザー(つまり、以前に登録したユーザー)です。
したがって、おおよその利用方法は次のとおりです。
1.変更するユーザー名と同じ名前でアカウントを登録しますが、登録時にスペースを追加します
2.クリックしてパスワードを変更し、メールボックスで確認してから、指定したユーザーパスワードを変更します