OAuth Account Takeover by hijacking custom schemes
カスタムスキームのハイジャックによるOAuthアカウントの乗っ取り
説明
この脆弱性は、OAuth認証中にアプリケーションが redirect_uri パラメータでカスタムスキームを使用していることに起因します。
典型的なOAuthシナリオでは、redirect_uri は、アイデンティティプロバイダー(Google、Facebook、Githubなど)にデータを要求するクライアントアプリケーション(client_id で識別される)に属することが保証されている必要があります。カスタムスキームを使用すると、ユーザーのデバイス上のアプリケーションによって要求される可能性があるため、その前提が崩れます。
攻撃シナリオの例としては、悪意のあるアプリが一部のOAuthクライアントアプリケーションで使用されているカスタムスキームを要求し、ターゲットアプリへのOAuth認証フローをトリガーする場合です。ユーザーがログインして同意すると、OAuthフローから生成された認証トークンを使用して悪意のあるアプリにリダイレクトされ、悪意のあるアプリがアカウントを乗っ取ることができます。
攻撃者は、エクスプレス認証フローなどの特定の技術を活用したり、ユーザーが以前に同意した場合に同意プロンプトをスキップすることを目的としたOAuthパラメータを使用したりすることで、ユーザーの操作をバイパスできます。
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
)
.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
}
// 認証リクエストを構築します
let request = OIDAuthorizationRequest(configuration: configuration,
clientId: clientID,
clientSecret: clientSecret,
scopes: [OIDScopeOpenID, OIDScopeProfile],
redirectURL: "mycustomscheme://oauthredirect",
responseType: OIDResponseTypeCode,
additionalParameters: nil)
// 認証リクエストを実行します
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)
}
}
}
マルチプラットフォーム
// android/build.gradle
android {
// ...
defaultConfig {
// ...
// 次の行を追加します
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
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);
}
推奨事項
脆弱性に対処するには、認証トークンのリダイレクトにカスタムスキームを使用しないことを推奨します。
開発者は、代わりに次のいずれかのオプションを検討する必要があります。
- Android向けのGoogle Identity ServicesやFacebook Express Loginなどのアプリ間統合
- Android's verifiable AppLinks
- iOS associated domains
Kotlin
バックエンドで次のような形式の /.well-known/assetlinks.json をホストする必要があります。
[
{
"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" />
<!-- ユーザーが "http" スキームを使用する共有リンクをクリックした場合、
アプリはそのトラフィックを "https" に委譲できる必要があります。 -->
<data android:scheme="http" />
<data android:scheme="https" />
<!-- 検証する必要がある1つ以上のドメインを含めます。 -->
<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" // httpsスキームを使用したリダイレクトURI
)
.setScope(mConfiguration.getScope())
if (!TextUtils.isEmpty(loginHint)) {
authRequestBuilder.setLoginHint(loginHint)
}
mAuthRequest.set(authRequestBuilder.build())
iOS
iOSの場合、バックエンドで次のような形式の /.well-known/apple-app-site-association をホストする必要があります。
{
"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
}
// 認証リクエストを構築します
let request = OIDAuthorizationRequest(configuration: configuration,
clientId: clientID,
clientSecret: clientSecret,
scopes: [OIDScopeOpenID, OIDScopeProfile],
redirectURL: "https://auth.myapp.com/oauth/handler",
responseType: OIDResponseTypeCode,
additionalParameters: nil)
// 認証リクエストを実行します
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)
}
}
}
マルチプラットフォーム
// android/build.gradle
android {
// ...
defaultConfig {
// ...
// 次の行を追加します
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
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);
}
リンク
標準
- 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