Aller au contenu

OAuth Account Takeover by hijacking custom schemes

Prise de contrôle de compte OAuth par détournement de schémas personnalisés

Description

La vulnérabilité découle de l'utilisation par l'application d'un schéma personnalisé dans le paramètre redirect_uri lors de l'authentification OAuth.

Dans un scénario OAuth typique, il doit être garanti que redirect_uri appartient à l'application cliente (identifiée par client_id) qui demande des données à un fournisseur d'identité (Google, Facebook, Github...). L'utilisation d'un schéma personnalisé rompt ce principe car il peut être revendiqué par l'application sur l'appareil de l'utilisateur.

Un exemple de scénario d'attaque est lorsqu'une application malveillante revendique le schéma personnalisé utilisé par une application cliente OAuth et déclenche un flux d'authentification OAuth vers l'application cible. Une fois que l'utilisateur a réussi à se connecter et a donné son consentement, il sera redirigé vers l'application malveillante avec le jeton d'authentification généré par le flux OAuth, permettant à l'application malveillante de prendre le contrôle de son compte.

Les attaquants peuvent contourner l'interaction de l'utilisateur en tirant parti de certaines techniques telles que le flux d'authentification express ou en utilisant des paramètres OAuth destinés à ignorer l'invite de consentement si l'utilisateur a déjà donné son consentement auparavant.

Kotlin

<activity android:exported="true" android:name="net.openid.appauth.RedirectUriReceiverActivity">
    <intent-filter>
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data android:host="oauthredirect" android:scheme="mycustomscheme"/>
    </intent-filter>
</activity>
Log.i(TAG, "Creating auth request for login hint: $loginHint")
val authRequestBuilder: AuthorizationRequest.Builder = Builder(
    mAuthStateManager.getCurrent().getAuthorizationServiceConfiguration(),
    mClientId.get(),
    ResponseTypeValues.CODE,
    "mycustomscheme://oauthredirect" // URI de redirection avec schéma personnalisé
)
    .setScope(mConfiguration.getScope())
if (!TextUtils.isEmpty(loginHint)) {
    authRequestBuilder.setLoginHint(loginHint)
}
mAuthRequest.set(authRequestBuilder.build())

iOS

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  ...
  <key>CFBundleURLTypes</key>
  <array>
    <dict>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>CFBundleURLSchemes</key>
        <array>
          <string>mycustomscheme</string>
        </array>
      </dict>
  </array>
  ...
</dict>
</plist>
func doAuthWithAutoCodeExchange(configuration: OIDServiceConfiguration, clientID: String, clientSecret: String?) {

    guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
        self.logMessage("Error accessing AppDelegate")
        return
    }

    // construit la requête d'authentification
    let request = OIDAuthorizationRequest(configuration: configuration,
                                          clientId: clientID,
                                          clientSecret: clientSecret,
                                          scopes: [OIDScopeOpenID, OIDScopeProfile],
                                          redirectURL: "mycustomscheme://oauthredirect",
                                          responseType: OIDResponseTypeCode,
                                          additionalParameters: nil)

    // exécute la requête d'authentification
    logMessage("Initiating authorization request with scope: \(request.scope ?? "DEFAULT_SCOPE")")

    appDelegate.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request, presenting: self) { authState, error in

        if let authState = authState {
            self.setAuthState(authState)
            self.logMessage("Got authorization tokens. Access token: \(authState.lastTokenResponse?.accessToken ?? "DEFAULT_TOKEN")")
        } else {
            self.logMessage("Authorization error: \(error?.localizedDescription ?? "DEFAULT_ERROR")")
            self.setAuthState(nil)
        }
    }
}

Multiplateforme

// android/build.gradle

android {
    // ...
    defaultConfig {
        // ...
        // Ajoutez la ligne suivante
        manifestPlaceholders = [auth0Domain: "oauthredirect", auth0Scheme: "mycustomscheme"]
    }
    // ...
}
    final authorizationEndpoint =
        Uri.parse('http://example.com/oauth2/authorization');
    final tokenEndpoint = Uri.parse('http://example.com/oauth2/token');

    final identifier = 'my client identifier';
    final secret = 'my client secret';

    // URI de redirection avec schéma personnalisé
    final redirectUrl = Uri.parse('mycustomscheme://oauthredirect');

    final credentialsFile = File('~/.myapp/credentials.json');

    Future<oauth2.Client> createClient() async {
      var exists = await credentialsFile.exists();

      if (exists) {
        var credentials =
            oauth2.Credentials.fromJson(await credentialsFile.readAsString());
        return oauth2.Client(credentials, identifier: identifier, secret: secret);
      }

      var grant = oauth2.AuthorizationCodeGrant(
          identifier, authorizationEndpoint, tokenEndpoint,
          secret: secret);

      var authorizationUrl = grant.getAuthorizationUrl(redirectUrl);

      await redirect(authorizationUrl);
      var responseUrl = await listen(redirectUrl);

      return await grant.handleAuthorizationResponse(responseUrl.queryParameters);
    }

Recommandation

Pour corriger la vulnérabilité, il est recommandé de ne pas utiliser le schéma personnalisé pour rediriger les jetons d'authentification.

Les développeurs doivent plutôt envisager l'une des options suivantes :

Kotlin

vous devez avoir /.well-known/assetlinks.json hébergé sur votre backend avec un format comme celui-ci :

[
  {
    "relation": [
      "delegate_permission/common.handle_all_urls",
      "delegate_permission/common.get_login_creds"
    ],
    "target": {
      "namespace": "android_app",
      "package_name": "com.myapplication.android",
      "sha256_cert_fingerprints": [
        "APPLICATION_CERT_FINGERPRINT"
      ]
    }
  }
]
    <intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />

    <!-- Si un utilisateur clique sur un lien partagé qui utilise le schéma "http", votre
         application doit être capable de déléguer ce trafic vers "https". -->
    <data android:scheme="http" />
    <data android:scheme="https" />

    <!-- Incluez un ou plusieurs domaines qui doivent être vérifiés. -->
    <data android:host="auth.myapp.com" />
</intent-filter>
Log.i(TAG, "Creating auth request for login hint: $loginHint")
val authRequestBuilder: AuthorizationRequest.Builder = Builder(
    mAuthStateManager.getCurrent().getAuthorizationServiceConfiguration(),
    mClientId.get(),
    ResponseTypeValues.CODE,
    "https://auth.myapp.com/oauth/handler" // L'URI de redirection avec un schéma https
)
    .setScope(mConfiguration.getScope())
if (!TextUtils.isEmpty(loginHint)) {
    authRequestBuilder.setLoginHint(loginHint)
}
mAuthRequest.set(authRequestBuilder.build())

iOS

Pour iOS, vous devez avoir /.well-known/apple-app-site-association hébergé sur votre backend avec un format comme celui-ci :

{
    "applinks": {
        "details": [{
            "appID": "ABCDE12345.com.myapplication.ios",
            "paths": ["/oauth/redirect/*"]
        }]
    },
    "appclips":{
        "apps":[
            "ABCDE12345.com.myapplication.ios"
        ]
    },
    "webcredentials":{
        "apps":[
            "ABCDE12345.com.myapplication.ios"
        ]
    }
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    ...
    <key>com.apple.developer.associated-domains</key>
    <array>
        <string>applinks:auth.myapp.com</string>
    </array>
    ...
</dict>
</plist>
func doAuthWithAutoCodeExchange(configuration: OIDServiceConfiguration, clientID: String, clientSecret: String?) {

    guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
        self.logMessage("Error accessing AppDelegate")
        return
    }

    // construit la requête d'authentification
    let request = OIDAuthorizationRequest(configuration: configuration,
                                          clientId: clientID,
                                          clientSecret: clientSecret,
                                          scopes: [OIDScopeOpenID, OIDScopeProfile],
                                          redirectURL: "https://auth.myapp.com/oauth/handler",
                                          responseType: OIDResponseTypeCode,
                                          additionalParameters: nil)

    // exécute la requête d'authentification
    logMessage("Initiating authorization request with scope: \(request.scope ?? "DEFAULT_SCOPE")")

    appDelegate.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request, presenting: self) { authState, error in

        if let authState = authState {
            self.setAuthState(authState)
            self.logMessage("Got authorization tokens. Access token: \(authState.lastTokenResponse?.accessToken ?? "DEFAULT_TOKEN")")
        } else {
            self.logMessage("Authorization error: \(error?.localizedDescription ?? "DEFAULT_ERROR")")
            self.setAuthState(nil)
        }
    }
}

Multiplateforme

// android/build.gradle

android {
    // ...
    defaultConfig {
        // ...
        // Ajoutez la ligne suivante
        manifestPlaceholders = [auth0Domain: "auth.myapp.com", auth0Scheme: "https"]
    }
    // ...
}
    final authorizationEndpoint =
        Uri.parse('http://example.com/oauth2/authorization');
    final tokenEndpoint = Uri.parse('http://example.com/oauth2/token');

    final identifier = 'my client identifier';
    final secret = 'my client secret';

    // URI de redirection avec schéma personnalisé
    final redirectUrl = Uri.parse('https://auth.myapp.com/oauth/handler');

    final credentialsFile = File('~/.myapp/credentials.json');

    Future<oauth2.Client> createClient() async {
      var exists = await credentialsFile.exists();

      if (exists) {
        var credentials =
            oauth2.Credentials.fromJson(await credentialsFile.readAsString());
        return oauth2.Client(credentials, identifier: identifier, secret: secret);
      }

      var grant = oauth2.AuthorizationCodeGrant(
          identifier, authorizationEndpoint, tokenEndpoint,
          secret: secret);

      var authorizationUrl = grant.getAuthorizationUrl(redirectUrl);

      await redirect(authorizationUrl);
      var responseUrl = await listen(redirectUrl);

      return await grant.handleAuthorizationResponse(responseUrl.queryParameters);
    }

Liens

Normes

  • OWASP_MASVS_L1:
    • MSTG_PLATFORM_3
    • MSTG_PLATFORM_4
    • MSTG_STORAGE_6
  • OWASP_MASVS_L2:
    • MSTG_PLATFORM_3
    • MSTG_PLATFORM_4
    • MSTG_NETWORK_5
    • MSTG_STORAGE_6
  • PCI_STANDARDS:
    • REQ_2_2
    • REQ_6_2
    • REQ_6_3
    • REQ_8_3
    • REQ_11_3
  • OWASP_MASVS_v2_1:
    • MASVS_PLATFORM_1
  • HIPAA_CONTROLS:
    • SECURITY221
    • SECURITY212
    • SECURITY213
    • SECURITY252
  • SOC2_CONTROLS:
    • CC_2_1
    • CC_3_4
    • CC_4_1
    • CC_7_1
    • CC_7_2
    • CC_7_4
    • CC_7_5