OAuth Account Takeover by hijacking custom schemes
Toma de control de cuenta OAuth mediante secuestro de esquemas personalizados
Descripción
La vulnerabilidad surge del uso de un esquema personalizado en el parámetro redirect_uri por parte de la aplicación durante la autenticación OAuth.
En un escenario OAuth típico, se debe garantizar que redirect_uri pertenece a la aplicación cliente (identificada por client_id) que solicita datos a un proveedor de identidad (Google, Facebook, Github...). El uso de un esquema personalizado rompe esa premisa, ya que la aplicación en el dispositivo del usuario puede reclamarlo.
Un ejemplo de escenario de ataque es cuando una aplicación maliciosa reclama el esquema personalizado utilizado por alguna aplicación cliente de OAuth y desencadena un flujo de autenticación OAuth a la aplicación de destino. Una vez que el usuario inicia sesión y da su consentimiento correctamente, será redirigido a la aplicación maliciosa con el token de autenticación generado a partir del flujo OAuth, lo que permite a la aplicación maliciosa tomar el control de su cuenta.
Los atacantes pueden eludir la interacción del usuario aprovechando ciertas técnicas como el flujo de autenticación rápida o usando parámetros OAuth que están destinados a omitir el mensaje de consentimiento si el usuario dio su consentimiento antes.
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 redirección con esquema personalizado
)
.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
}
// construye la solicitud de autenticación
let request = OIDAuthorizationRequest(configuration: configuration,
clientId: clientID,
clientSecret: clientSecret,
scopes: [OIDScopeOpenID, OIDScopeProfile],
redirectURL: "mycustomscheme://oauthredirect",
responseType: OIDResponseTypeCode,
additionalParameters: nil)
// realiza la solicitud de autenticación
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)
}
}
}
Multiplataforma
// android/build.gradle
android {
// ...
defaultConfig {
// ...
// Agregue la siguiente línea
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 redirección con esquema personalizado
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);
}
Recomendación
Para abordar la vulnerabilidad, se recomienda no usar el esquema personalizado para redirigir los tokens de autenticación.
En su lugar, los desarrolladores deben considerar una de las siguientes opciones:
- Integración de aplicación a aplicación como Google Identity Services y Facebook Express Login para Android
- Android's verifiable AppLinks
- iOS associated domains
Kotlin
debe tener /.well-known/assetlinks.json alojado en su backend con un formato como este:
[
{
"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 usuario hace clic en un enlace compartido que usa el esquema "http", su
aplicación debería poder delegar ese tráfico a "https". -->
<data android:scheme="http" />
<data android:scheme="https" />
<!-- Incluya uno o más dominios que deban verificarse. -->
<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" // La URI de redirección con un esquema https
)
.setScope(mConfiguration.getScope())
if (!TextUtils.isEmpty(loginHint)) {
authRequestBuilder.setLoginHint(loginHint)
}
mAuthRequest.set(authRequestBuilder.build())
iOS
Para iOS, debe tener /.well-known/apple-app-site-association alojado en su backend con un formato como este:
{
"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
}
// construye la solicitud de autenticación
let request = OIDAuthorizationRequest(configuration: configuration,
clientId: clientID,
clientSecret: clientSecret,
scopes: [OIDScopeOpenID, OIDScopeProfile],
redirectURL: "https://auth.myapp.com/oauth/handler",
responseType: OIDResponseTypeCode,
additionalParameters: nil)
// realiza la solicitud de autenticación
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)
}
}
}
Multiplataforma
// android/build.gradle
android {
// ...
defaultConfig {
// ...
// Agregue la siguiente línea
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 redirección con esquema personalizado
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);
}
Enlaces
Estándares
- 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