usd-2025-0002 | Element Android 1.6.x - PIN request Bypass

Product: Element Android
Affected Version: 1.6.x
Vulnerability Type: Broken Access (CWE-284)
Security Risk: Medium
Vendor: Element
Vendor URL: https://github.com/element-hq/element-android
Vendor acknowledged vulnerability: Yes
Vendor Status: Fixed
CVE number: CVE-2025-27606
CVE Link: https://www.cve.org/CVERecord?id=CVE-2025-27606
Advisory ID: usd-2025-0002

Description

A brute force attack on the PIN code is possible if the PIN code is set and the internet connection is deactivated.
The session is not invalidated and the PIN can then be used to gain full access to the application. As the PIN code is a four-digit number, this attack can be carried out in a short time.

The following code snippets are from the open source client Element Android. The underlying vulnerability is located in MainActivity.kt.

Proof of Concept

If the PIN code is entered incorrectly, the method onWrongPin() is called, which is located in the file vector/src/main/java/im/vector/app/features/pin/PinFragment.kt:

private fun onWrongPin() {
    val remainingAttempts = pinCodeStore.onWrongPin()
    when {
        remainingAttempts > 1 ->
            requireActivity().toast(resources.getQuantityString(CommonPlurals.wrong_pin_message_remaining_attempts, remainingAttempts, remainingAttempts))
        remainingAttempts == 1 ->
            requireActivity().toast(CommonStrings.wrong_pin_message_last_remaining_attempt)
        else -> {
            requireActivity().toast(CommonStrings.too_many_pin_failures)
            // Logout
            launchResetPinFlow()
        }
    }
}

This checks the number of remaining input attempts and calls the launchResetPinFlow() function if an incorrect PIN has been entered too often:

  private fun launchResetPinFlow() {
      MainActivity.restartApp(requireActivity(), MainActivityArgs(clearCredentials = true))
  }

The restartApp() method starts a new activity with the argument clearCredentials = true:

fun restartApp(activity: Activity, args: MainActivityArgs) {
    val intent = Intent(activity, MainActivity::class.java)
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)

    intent.putExtra(EXTRA_ARGS, args)
    activity.startActivity(intent)
}

This and all subsequent methods originate from the file vector/src/main/java/im/vector/app/features/MainActivity.kt.

During the restart, the function handleAppStarted() is called, which defines how the clearCredentials argument is handled. By passing clearCredentials = true, the two methods clearNotifications() and doCleanup() are called:

    [...]
    args = parseArgs()
    if (args.clearCredentials || args.isUserLoggedOut || args.clearCache) {
        clearNotifications()
    }
    // Handle some wanted cleanup
    if (args.clearCache || args.clearCredentials) {
        doCleanUp()
    } else {
    [...]

The latter passes the argument ignoreServerError = false to the method signout():

private fun doCleanUp() {
    val session = activeSessionHolder.getSafeActiveSession()
    if (session == null) {
        startNextActivityAndFinish()
        return
    }

    val onboardingStore = session.vectorStore(this)
    when {
        args.isAccountDeactivated -> {
            lifecycleScope.launch {
                // Just do the local cleanup
                Timber.w("Account deactivated, start app")
                activeSessionHolder.clearActiveSession()
                doLocalCleanup(clearPreferences = true, onboardingStore)
                startNextActivityAndFinish()
            }
        }

        args.clearCredentials -> {
            signout(session, onboardingStore, ignoreServerError = false)
        }
        args.clearCache -> {
            lifecycleScope.launch {
                session.clearCache()
                doLocalCleanup(clearPreferences = false, onboardingStore)
                session.startSyncing(applicationContext)
                startNextActivityAndFinish()
            }
        }
    }
}

This method handles the actual signout process:

private fun signout(
      session: Session,
      onboardingStore: VectorSessionStore,
      ignoreServerError: Boolean,
) {
  lifecycleScope.launch {
      try {
          session.signOutService().signOut(!args.isUserLoggedOut, ignoreServerError)
      } catch (failure: Throwable) {
          Timber.e(failure, "SIGN_OUT: error, propose to sign out anyway")
          displaySignOutFailedDialog(session, onboardingStore)
          return@launch
      }
      Timber.w("SIGN_OUT: success, start app")
      activeSessionHolder.clearActiveSession()
      doLocalCleanup(clearPreferences = true, onboardingStore)
      startNextActivityAndFinish()
  }
}

If an error occurs, the displaySignOutFailedDialog is displayed, which offers the three buttons sign_out_anyway (Sign out anyway), global_retry (Retry) and action_cancel (Cancel):

private fun displaySignOutFailedDialog(
      session: Session,
      onboardingStore: VectorSessionStore,
) {
  if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
      MaterialAlertDialogBuilder(this)
              .setTitle(CommonStrings.dialog_title_error)
              .setMessage(CommonStrings.sign_out_failed_dialog_message)
              .setPositiveButton(CommonStrings.sign_out_anyway) { _, _ ->
                  signout(session, onboardingStore, ignoreServerError = true)
              }
              .setNeutralButton(CommonStrings.global_retry) { _, _ ->
                  signout(session, onboardingStore, ignoreServerError = false)
              }
              .setNegativeButton(CommonStrings.action_cancel) { _, _ -> startNextActivityAndFinish(ignoreClearCredentials = true) }
              .setCancelable(false)
              .show()
  }
}

If Cancel is selected, startNextActivityAndFinish is called with the parameter ignoreClearCredentials = true. As the active session has not yet been deleted, it is retrieved and the HomeActivity is then opened:

private fun startNextActivityAndFinish(ignoreClearCredentials: Boolean = false) {
  val intent = when {
      args.clearCredentials &&
            !ignoreClearCredentials &&
            (!args.isUserLoggedOut || args.isAccountDeactivated) -> {
          // User has explicitly asked to log out or deactivated his account
          navigator.openLogin(this, null)
          null
      }
      args.isSoftLogout -> {
          // The homeserver has invalidated the token, with a soft logout
          navigator.softLogout(this)
          null
      }
      args.isUserLoggedOut ->
          // the homeserver has invalidated the token (password changed, device deleted, other security reasons)
          SignedOutActivity.newIntent(this)
      activeSessionHolder.hasActiveSession() ->
          // We have a session.
          // Check it can be opened
          if (activeSessionHolder.getActiveSession().isOpenable) {
              HomeActivity.newIntent(this, firstStartMainActivity = false, existingSession = true)
          } else {
              // The token is still invalid
              navigator.softLogout(this)
              null
          }
      else -> {
          // First start, or no active session
          navigator.openLogin(this, null)
          null
      }
  }
  startIntentAndFinish(intent)
}

The app then asks for the PIN code again and allows the next attempts to be entered. This allows the PIN code to be brute-forced when the internet connection is disconnected.

The session data is not deleted when the signout function returns. This happens because of the error in the catch block at line 319. As a result, the PIN can be used after a brute force attack to unlock the app when the internet connection is reactivated. This allows the user to continue using the still-active session and gain full access to all content.
The session data would be deleted in the doLocalCleanup function, which is called in line 323 of the signout method. However, this code point is not reached if the logout fails.

Fix

It is recommended to set the parameter ignoreServerError, which is used to call the signout function in line 295, to true. This may impair the user-friendliness, but represents a short-term and easy-to-implement solution for the described vulnerability.

References

Timeline

  • 2025-01-23: First contact request via mail.
  • 2025-01-27: Vendor acknowledged the vulnerability and were able to reproduce it.
  • 2025-03-13: Vulnerability is patched in version 1.6.34.
  • 2025-03-28: This advisory is published.

Credits

This security vulnerability was identified by Dominique Dittert, Tobias Hamann and Fabian Brenner of usd AG.