Low severityNVD Advisory· Published Jan 30, 2024· Updated Oct 17, 2024
vantage6 username timing attack
CVE-2024-21671
Description
The vantage6 technology enables to manage and deploy privacy enhancing technologies like Federated Learning (FL) and Multi-Party Computation (MPC). It is possible to find out usernames from the response time of login requests. This could aid attackers in credential attacks. Version 4.2.0 patches this vulnerability.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
vantage6-serverPyPI | < 4.2.0 | 4.2.0 |
Affected products
1Patches
1389f416c445dMerge pull request from GHSA-45gq-q4xh-cp53
4 files changed · +191 −51
docs/server/yaml/server_config.yaml+14 −0 modified@@ -157,3 +157,17 @@ ui: # port at which the UI will be available on your local machine port: 3456 + +# set password policies for the server +password_policy: + # maximum number of failed login attempts before the user is locked out for + # a certain amount of time. Default is 5. + max_failed_attempts: 5 + + # number of minutes the user is locked out after the maximum number of failed + # login attempts is reached. Default is 15. + inactivation_minutes: 15 + + # number of minutes to wait between emails that alert a user that someone is + # trying to log in to their account. Default is 60. + between_email_blocked_login_minutes: 60
vantage6-server/vantage6/server/globals.py+5 −0 modified@@ -54,3 +54,8 @@ # pagination settings DEFAULT_PAGE = 1 DEFAULT_PAGE_SIZE = 10 + +# default password policies +DEFAULT_MAX_FAILED_ATTEMPTS = 5 +DEFAULT_INACTIVATION_MINUTES = 15 +DEFAULT_BETWEEN_BLOCKED_LOGIN_EMAIL_MINUTES = 60
vantage6-server/vantage6/server/model/user.py+23 −4 modified@@ -65,6 +65,7 @@ class User(Authenticatable): failed_login_attempts = Column(Integer, default=0) last_login_attempt = Column(DateTime) otp_secret = Column(String(32)) + last_email_failed_login_sent = Column(DateTime) # relationships organization = relationship("Organization", back_populates="users") @@ -155,7 +156,7 @@ def check_password(self, pw: str) -> bool: return False def is_blocked(self, max_failed_attempts: int, - inactivation_in_minutes: int) -> tuple[bool, str | None]: + inactivation_in_minutes: int) -> tuple[bool, int | None]: """ Check if user can login or if they are temporarily blocked because they entered a wrong password too often @@ -164,15 +165,15 @@ def is_blocked(self, max_failed_attempts: int, ---------- max_failed_attempts: int Maximum number of attempts to login before temporary deactivation - inactivation_minutes: int + inactivation_in_minutes: int How many minutes an account is deactivated Returns ------- bool Whether or not user is blocked temporarily - str | None - Message if user is blocked, else None + int | None + How many minutes user is still blocked for """ td_max_blocked = dt.timedelta(minutes=inactivation_in_minutes) td_last_login = dt.datetime.now() - self.last_login_attempt \ @@ -213,6 +214,24 @@ def get_by_username(cls, username: str) -> User: session.commit() return result + @classmethod + def get_first_user(cls) -> User: + """ + Get a random user by their username. + + This function is used to prevent an attacker from finding out which + usernames exist. + + Returns + ------- + User + A random user that is in the database + """ + session = DatabaseSessionManager.get_session() + result = session.query(cls).order_by(cls.id).first() + session.commit() + return result + @classmethod def get_by_email(cls, email: str) -> User: """
vantage6-server/vantage6/server/resource/common/auth_helper.py+149 −47 modified@@ -1,14 +1,18 @@ +import sys import logging import datetime as dt import pyotp from http import HTTPStatus -from flask import request, render_template +from flask import request, render_template, current_app, Flask from flask_mail import Mail +from threading import Thread from vantage6.common.globals import APPNAME, MAIN_VERSION_NAME from vantage6.server.globals import ( - DEFAULT_SUPPORT_EMAIL_ADDRESS, DEFAULT_EMAIL_FROM_ADDRESS + DEFAULT_SUPPORT_EMAIL_ADDRESS, DEFAULT_MAX_FAILED_ATTEMPTS, + DEFAULT_INACTIVATION_MINUTES, DEFAULT_BETWEEN_BLOCKED_LOGIN_EMAIL_MINUTES, + DEFAULT_EMAIL_FROM_ADDRESS ) from vantage6.server.model.user import User @@ -40,84 +44,182 @@ def user_login( HTTPStatus: Status code that the current request should return """ - log.info(f"Trying to login '{username}'") + log.info("Trying to login '%s'", username) failed_login_msg = "Failed to login" - if User.username_exists(username): - user = User.get_by_username(username) - password_policy = config.get("password_policy", {}) - max_failed_attempts = password_policy.get('max_failed_attempts', 5) - inactivation_time = password_policy.get('inactivation_minutes', 15) - - is_blocked, min_rem = user.is_blocked(max_failed_attempts, - inactivation_time) - if is_blocked: - notify_user_blocked(user, max_failed_attempts, min_rem, mail, - config) - return {"msg": failed_login_msg}, HTTPStatus.UNAUTHORIZED - elif user.check_password(password): - user.failed_login_attempts = 0 - user.save() - return user, HTTPStatus.OK - else: - # update the number of failed login attempts - user.failed_login_attempts = 1 \ - if ( - not user.failed_login_attempts or - user.failed_login_attempts >= max_failed_attempts - ) else user.failed_login_attempts + 1 - user.last_login_attempt = dt.datetime.now() - user.save() + + # check if username exists. If it does not, we continue anyway, to prevent + # that an attacker can find out which usernames exist via a timing attack. + # In that case, we fetch the first user as random user. + username_exists = User.username_exists(username) + random_username = User.get_first_user().username + user = User.get_by_username(username) if username_exists \ + else User.get_by_username(random_username) + + password_policy = config.get("password_policy", {}) + max_failed_attempts = password_policy.get( + 'max_failed_attempts', DEFAULT_MAX_FAILED_ATTEMPTS + ) + inactivation_time = password_policy.get( + 'inactivation_minutes', DEFAULT_INACTIVATION_MINUTES + ) + + is_blocked, min_rem = user.is_blocked(max_failed_attempts, + inactivation_time) + + if user.check_password(password) and not is_blocked and username_exists: + # Note: above the username_exists is checked to prevent that an + # attacker happens to get the correct password for the random user + # that is returned when the username does not exist. Note also that + # the password is checked first to keep the timing equal for both. + user.failed_login_attempts = 0 + user.save() + return user, HTTPStatus.OK + + # Handle database updates required upon failed login in a separate thread + # to ensure similar response times + # pylint: disable=W0212 + t1 = Thread(target=__handle_failed_login, args=( + current_app._get_current_object(), username_exists, username, + password_policy, is_blocked, min_rem, mail, config, + request.access_route[-1] + )) + t1.start() return {"msg": failed_login_msg}, HTTPStatus.UNAUTHORIZED -def notify_user_blocked( - user: User, max_n_attempts: int, min_rem: int, mail: Mail, +def __handle_failed_login( + app: Flask, user_exists: bool, username: str, password_policy: dict, + is_blocked: bool, min_rem: int, mail: Mail, config: dict, ip: str +) -> None: + """ + When a user login fails, this function is called to update the database + with the failed login attempt and send an email to the user if necessary. + + Note that this function is called in a separate thread to keep response + times for login attempts similar in all cases. Therefore, this function + calls `sys.exit()` to terminate the thread. + + Parameters + ---------- + app: flask.Flask + The current Flask app + user_exists: bool + Whether user exists or not + username: str + Username of the user that failed to login + password_policy: dict + Dictionary with password policy settings. + min_rem: int + Number of minutes remaining before the account is unlocked + mail: flask_mail.Mail + An instance of the Flask mail class. Used to send email to user in case + of too many failed login attempts. config: dict + Dictionary with configuration settings + ip: str + IP address from where the login attempt was made + """ + if not user_exists: + sys.exit() + # get user object again (required because we are in a new thread) + user = User.get_by_username(username) + + max_failed_attempts = password_policy.get( + 'max_failed_attempts', DEFAULT_MAX_FAILED_ATTEMPTS + ) + + if is_blocked: + # alert the user via email that they are blocked + __notify_user_blocked(app, user, min_rem, mail, config, ip) + sys.exit() + elif ( + not user.failed_login_attempts or + user.failed_login_attempts >= max_failed_attempts + ): + # set failed login attempts to 1 if first failed login attempt or if + # user got unblocked after being blocked previously + user.failed_login_attempts = 1 + else: + user.failed_login_attempts += 1 + user.last_login_attempt = dt.datetime.now() + user.save() + sys.exit() + + +def __notify_user_blocked( + app: Flask, user: User, min_rem: int, mail: Mail, config: dict, ip: str ) -> None: """ - Sends an email to the user when his or her account is locked + Sends an email to the user when their account is locked. + + This function also checks that emails are not sent too often to the same + user. Parameters ---------- + app: flask.Flask + The current Flask app user: :class:`~vantage6.server.model.user.User` User who is temporarily blocked - max_n_attempts: int - Maximum number of failed login attempts before the account is locked min_rem: int Number of minutes remaining before the account is unlocked mail: flask_mail.Mail An instance of the Flask mail class. Used to send email to user in case of too many failed login attempts. config: dict Dictionary with configuration settings + ip: str + IP address from where the login attempt was made """ - if not user.email: - log.warning(f'User {user.username} is locked, but does not have' - 'an email registered. So no message has been sent.') + log.info('User %s is locked. Sending them an email.', user.username) - log.info(f'User {user.username} is locked. Sending them an email.') + # check that email has not already been sent recently + password_policy = config.get("password_policy", {}) + minutes_between_blocked_emails = password_policy.get( + 'between_email_blocked_login_minutes', + DEFAULT_BETWEEN_BLOCKED_LOGIN_EMAIL_MINUTES + ) + email_sent_recently = user.last_email_failed_login_sent and ( + dt.datetime.now() < user.last_email_failed_login_sent + + dt.timedelta(minutes=minutes_between_blocked_emails) + ) + if email_sent_recently: + return + # send email smtp_settings = config.get("smtp", {}) email_from = smtp_settings.get("email_from", DEFAULT_EMAIL_FROM_ADDRESS) support_email = config.get("support_email", DEFAULT_SUPPORT_EMAIL_ADDRESS) + max_failed_attempts = password_policy.get( + 'max_failed_attempts', DEFAULT_MAX_FAILED_ATTEMPTS + ) template_vars = { - 'firstname': user.firstname, - 'number_of_allowed_attempts': max_n_attempts, - 'ip': request.access_route[-1], + 'firstname': user.firstname if user.firstname else user.username, + 'number_of_allowed_attempts': max_failed_attempts, + 'ip': ip, 'time': dt.datetime.now(dt.timezone.utc), 'time_remaining': min_rem, 'support_email': support_email, } - mail.send_email( - "Failed login attempts on your vantage6 account", - sender=email_from, - recipients=[user.email], - text_body=render_template("mail/blocked_account.txt", **template_vars), - html_body=render_template("mail/blocked_account.html", **template_vars) - ) + with app.app_context(): + mail.send_email( + "Failed login attempts on your vantage6 account", + sender=email_from, + recipients=[user.email], + text_body=render_template( + "mail/blocked_account.txt", **template_vars + ), + html_body=render_template( + "mail/blocked_account.html", **template_vars + ) + ) + + # Update latest email sent timestamp + user.last_email_failed_login_sent = dt.datetime.now() + user.save() def create_qr_uri(user: User) -> dict:
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-45gq-q4xh-cp53ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-21671ghsaADVISORY
- github.com/pypa/advisory-database/tree/main/vulns/vantage6/PYSEC-2024-31.yamlghsaWEB
- github.com/vantage6/vantage6/commit/389f416c445da4f2438c72f34c3b1084485c4e30ghsax_refsource_MISCWEB
- github.com/vantage6/vantage6/security/advisories/GHSA-45gq-q4xh-cp53ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.