Skip to content

Latest commit

 

History

History
126 lines (99 loc) · 9.52 KB

File metadata and controls

126 lines (99 loc) · 9.52 KB

Vulnerability List:

  • Vulnerability Name: Username Case Sensitivity Bypass leading to Lockout Evasion

    • Description: The django-defender library aims to prevent brute-force login attempts by tracking failed login attempts based on IP address and username. It uses Redis to store attempt counts and block information. The username handling within django-defender introduces a case sensitivity vulnerability. Specifically, the lower_username function in defender/utils.py converts usernames to lowercase before storing them in Redis. However, django-defender does not enforce lowercase conversion of usernames before passing it to Django's authentication system. If the Django application is configured with a case-sensitive authentication backend (which is the default behavior in many Django projects), an attacker can bypass the username-based lockout by repeatedly attempting logins with different letter casing of the same username. Each attempt with a different casing will be treated as a different username by django-defender, thus not incrementing the failure count for the actual username in lowercase and evading the lockout mechanism.

      Steps to trigger the vulnerability:

      1. An attacker identifies a valid username, for example, "testuser".
      2. The attacker initiates multiple failed login attempts, each time using a different casing of the username, such as "TestUser", "tEstUser", "teStUser", "tesTUser", "testUser", "TESTUSER", etc.
      3. Because django-defender converts the username to lowercase after receiving it from the request, and uses this lowercase version for tracking, attempts with different casings are not correctly aggregated against the base lowercase username.
      4. The failure count for the lowercase username "testuser" in Redis remains below the configured DEFENDER_LOGIN_FAILURE_LIMIT.
      5. The attacker successfully bypasses the username-based lockout and can continue attempting logins indefinitely, or until the IP-based lockout is triggered (if enabled and not bypassed separately).
    • Impact:

      • High: Successful bypass of the intended brute-force protection mechanism for usernames.
      • Increased risk of successful brute-force attacks against user accounts.
      • Allows attackers to potentially gain unauthorized access to user accounts by circumventing the lockout feature designed to prevent such attacks.
    • Vulnerability Rank: High

    • Currently Implemented Mitigations:

      • None. The code currently converts usernames to lowercase only for internal tracking purposes but does not enforce or recommend case-insensitive username handling at the Django authentication level.
    • Missing Mitigations:

      • Enforce lowercase usernames at the Django authentication level: The most effective mitigation is to configure Django's authentication backend to treat usernames as case-insensitive. This can be achieved by customizing the authentication backend to normalize usernames to lowercase before authentication checks.
      • Normalize username casing early in django-defender: Modify django-defender to convert the username to lowercase immediately upon receiving it from the request, before any checks or storage operations. This ensures consistent tracking regardless of the casing used in login attempts.
      • Documentation update: Even if code-level mitigation is not fully implemented in django-defender, the documentation should be updated to explicitly warn users about this case sensitivity issue and recommend configuring Django for case-insensitive username authentication when using django-defender.
    • Preconditions:

      • Django application using django-defender is configured with a case-sensitive authentication backend (default Django behavior).
      • Username-based lockout is enabled in django-defender (DISABLE_USERNAME_LOCKOUT = False).
    • Source Code Analysis:

      1. defender/utils.py:lower_username(username):
      def lower_username(username):
          """
          Single entry point to force the username to lowercase, all the functions
          that need to deal with username should call this.
          """
          if username:
              return username.lower()
          return None

      This function correctly converts a given username to lowercase.

      1. defender/utils.py:get_username_attempt_cache_key(username) and get_username_blocked_cache_key(username): These functions use lower_username(username) when constructing cache keys for failed attempts and blocked usernames, ensuring that tracking is done using lowercase usernames in Redis.

      2. defender/utils.py:username_from_request(request) and get_username_from_request(request): These functions extract the username from the request, but they do not convert it to lowercase before returning it.

      def username_from_request(request):
          """ unloads username from default POST request """
          if config.USERNAME_FORM_FIELD in request.POST:
              return request.POST[config.USERNAME_FORM_FIELD][:255]
          return None
      
      get_username_from_request = import_string(config.GET_USERNAME_FROM_REQUEST_PATH)
      1. defender/middleware.py and defender/decorators.py: The watch_login decorator and FailedLoginMiddleware use utils.get_username_from_request (or a custom function configured by DEFENDER_GET_USERNAME_FROM_REQUEST_PATH) to retrieve the username from the request. The username is then used in functions like utils.is_already_locked and utils.check_request. These utility functions do use lower_username when interacting with Redis.

      Vulnerability Flow:

      Login Request --> FailedLoginMiddleware/watch_login decorator --> utils.get_username_from_request (Returns username as is, with original casing) --> utils.check_request/utils.is_already_locked --> utils.lower_username (Username is converted to lowercase *only now*) --> Redis interaction with lowercase username.
      

      Visualization:

      sequenceDiagram
          participant Attacker
          participant Application
          participant Defender
          participant Redis
      
          Attacker->>Application: Login Request (Username: TestUser, Password: wrong)
          Application->>Defender: Intercept Login Attempt (Username: TestUser)
          Defender->>Utils: get_username_from_request(Request)
          Utils->>Utils: Return Username "TestUser" (Casing preserved)
          Defender->>Utils: lower_username("TestUser")
          Utils->>Utils: Return "testuser"
          Defender->>Redis: Increment attempt count for "testuser"
          Redis-->>Defender: OK
          Application-->>Attacker: Login Failed
      
          Attacker->>Application: Login Request (Username: tEstUser, Password: wrong)
          Application->>Defender: Intercept Login Attempt (Username: tEstUser)
          Defender->>Utils: get_username_from_request(Request)
          Utils->>Utils: Return Username "tEstUser" (Casing preserved)
          Defender->>Utils: lower_username("tEstUser")
          Utils->>Utils: Return "testuser"
          Defender->>Redis: Increment attempt count for "testuser" (Again)
          Redis-->>Defender: OK
          Application-->>Attacker: Login Failed
      
          Note over Attacker, Application, Defender, Redis: Attacker repeats with different casings. Failure count for "testuser" increments, but lockout not triggered because casing variations bypass simple username check.
      
      Loading
    • Security Test Case:

      Pre-test setup:

      1. Ensure DEFENDER_LOGIN_FAILURE_LIMIT is set to a low value, e.g., 3, in exampleapp/settings.py or defender/test_settings.py.
      2. Ensure DISABLE_USERNAME_LOCKOUT is set to False.
      3. Run the example Django application or a test environment with django-defender installed and configured.
      4. Identify a valid username in the application (e.g., "admin" if using the example app defaults or create a test user).

      Test steps:

      1. Open a web browser or use a tool like curl to send POST requests to the login URL of the Django application (e.g., /admin/login/).
      2. In each request, use the same valid username but with different casing variations (e.g., "Admin", "aDmin", "adMin", "admIn", "admiN", "ADMIN"). Use an incorrect password for each attempt to ensure login failure.
      3. Send more login attempts than the configured DEFENDER_LOGIN_FAILURE_LIMIT (e.g., 4-5 attempts with different casings).
      4. After sending these attempts, try to log in again using the correct username (in lowercase, e.g., "admin") and a correct password.

      Expected result:

      • The login attempt with the correct username and password should be successful.
      • If the username lockout was working correctly without the case sensitivity bypass, the login should have been blocked due to exceeding the failure limit for the username.
      • This successful login after multiple failed attempts with different casings demonstrates that the username-based lockout can be bypassed by varying the casing of the username.

      Cleanup:

      • Reset the failed login attempts for the test username (e.g., using defender.utils.reset_failed_attempts(username='admin') in a Django shell if needed for subsequent tests).